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

No files matched your search

+4
View File
@@ -30,6 +30,10 @@ NOTICES = {
"approved": ("success", "پیامک تأیید شد و برای ارسال در صف قرار گرفت."),
"not_pending": ("warning", "این پیامک در وضعیت «در انتظار تأیید» نیست."),
"deleted": ("success", "پیامک از صف حذف شد."),
"delete_done_blocked": (
"warning",
"این پیامک ارسال شده است و از صف حذف نمی‌شود؛ سابقهٔ آن در «گزارش ارسال» می‌ماند.",
),
"bulk_approved_all": ("success", "{count} پیامک تأیید شد و برای ارسال در صف قرار گرفت."),
"bulk_approved": ("success", "{count} پیامک تأیید شد."),
"bulk_deleted": ("success", "{count} پیامک از صف حذف شد."),
+90
View File
@@ -0,0 +1,90 @@
"""محاسبهٔ «تعداد پیامکِ محاسبه‌شده» (parts) و کدگذاری متن.
هر پیامک روی شبکهٔ موبایل بر اساس **کدگذاری** و **طول** به یک یا چند «بخش» (part)
شکسته می‌شود و اپراتور بر همان تعداد بخش هزینه می‌گیرد. این ماژول همان قاعدهٔ
استاندارد (3GPP/GSM 03.38) را پیاده می‌کند تا بتوان هزینهٔ ارسال را تحلیل کرد:
* **GSM-7** — برای متن‌های انگلیسی/عددی. هر بخش ۱۶۰ کاراکتر؛ در حالت چندبخشی
(multipart) هر بخش ۱۵۳ کاراکتر (۷ کاراکتر برای هدر الحاق).
برخی کاراکترها (مثل `{}[]~\\|^€`) در جدول «الحاقی» هستند و **۲ کاراکتر** حساب
می‌شوند.
* **UCS-2** — اگر متن حتی **یک** کاراکتر خارج از جدول GSM-7 داشته باشد (فارسی،
عربی، ایموجی…)، **کل پیامک** با UCS-2 کدگذاری می‌شود: هر بخش ۷۰ کاراکتر؛ در
حالت چندبخشی هر بخش ۶۷ کاراکتر. ایموجی‌ها (خارج از BMP) دو واحد کدِ UTF-16
حساب می‌شوند.
بنابراین یک متن فارسی حتی اگر کوتاه باشد، در «۷۰ کاراکتر» جا می‌شود و ممکن است
چند پیامک حساب شود؛ و یک متن ترکیبی فارسی+انگلیسی همیشه UCS-2 است.
"""
from math import ceil
ENCODING_GSM7 = "gsm7"
ENCODING_UCS2 = "ucs2"
# جدول کامل GSM 03.38 (بخش پایه). کاراکتر 0x1B عمداً وارد نشده چون نقش «پیشوند
# الحاق» دارد و کاراکتر مستقل قانونی نیست.
GSM7_BASIC = set(
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ"
" !\"#¤%&'()*+,-./0123456789:;<=>?"
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ"
"¿abcdefghijklmnopqrstuvwxyzäöñüà"
)
# کاراکترهای «جدول الحاقی»: در GSM-7 دو واحد حساب می‌شوند.
GSM7_EXTENDED = set("^{}\\[~]|€")
# (سقف تک‌بخشی، سقف هر بخش چندبخشی) برای هر کدگذاری.
LIMITS = {
ENCODING_GSM7: (160, 153),
ENCODING_UCS2: (70, 67),
}
ENCODING_LABELS = {
ENCODING_GSM7: "GSM-7 (انگلیسی/عدد)",
ENCODING_UCS2: "UCS-2 (فارسی/یونیکد)",
}
def is_gsm7(text):
"""آیا کل متن با جدول GSM-7 قابل نمایش است؟ (شرط کافی و لازم برای GSM-7)."""
return all(ch in GSM7_BASIC or ch in GSM7_EXTENDED for ch in text or "")
def detect_encoding(text):
"""کدگذاری متن: ``gsm7`` یا ``ucs2``. متن خالی = ``gsm7``."""
return ENCODING_GSM7 if is_gsm7(text) else ENCODING_UCS2
def encoded_length(text):
"""طول متن بر حسب «واحدهایی که اپراتور می‌شمارد».
* GSM-7: کاراکترهای الحاقی ۲ واحد.
* UCS-2: واحدهای کد UTF-16 (ایموجی = ۲).
"""
text = text or ""
if is_gsm7(text):
return sum(2 if ch in GSM7_EXTENDED else 1 for ch in text)
return len(text.encode("utf-16-le")) // 2
def parts_count(text):
"""تعداد بخش‌هایی که اپراتور برای این متن حساب می‌کند (۱ برای متن خالی = ۰)."""
text = text or ""
if not text:
return 0
single, multipart = LIMITS[detect_encoding(text)]
length = encoded_length(text)
if length <= single:
return 1
return ceil(length / multipart)
def describe(text):
"""خلاصهٔ محاسبه برای نمایش در گزارش (کدگذاری، طول، تعداد بخش)."""
encoding = detect_encoding(text)
return {
"encoding": encoding,
"encoding_label": ENCODING_LABELS[encoding],
"length": encoded_length(text),
"parts": parts_count(text),
}
+1 -2
View File
@@ -4,7 +4,7 @@ from ..models import CustomerSMSSetting, SMSType
def is_sms_enabled(customer, sms_type):
"""اگر رکوردی با is_enabled=False وجود داشته باشد غیرفعال است؛ در غیر این‌صورت فعال."""
if customer is None or sms_type == SMSType.CUSTOM:
if customer is None:
return True
setting = CustomerSMSSetting.objects.filter(customer=customer, sms_type=sms_type).first()
if setting is None:
@@ -21,5 +21,4 @@ def customer_toggle_map(customer):
return [
{"code": code, "label": label, "enabled": overrides.get(code, True)}
for code, label in SMSType.choices
if code != SMSType.CUSTOM
]
+27 -137
View File
@@ -9,31 +9,29 @@
در هر اجرا حداکثر ``backlog_max`` مورد قدیمی هم صف می‌شود.
* **سقف هر اجرا (``max_per_run``)**: سقف کل صف‌کردن هر اسکنر در یک اجرا.
اسکنرها فقط انواع «دوره‌ای» را پوشش می‌دهند (تأمین موجودی چک، سررسید نسیه، بازدید
دوره‌ای). انواع «رویدادی» (خوش‌آمدگویی، نظرسنجی، ثبت بازدید، تشکر از پرداخت) هنگام
وقوع رخداد توسط ویوها و از طریق ``admin_panel/sms/triggers.py`` صف می‌شوند.
هر اسکنر مستقل و مقاوم به خطاست؛ یک خطا بقیه را متوقف نمی‌کند. ``dedup_key`` از
ارسال تکراری جلوگیری می‌کند و تضمین می‌کند اجرای دوبارهٔ اسکنر رکورد جدیدی نسازد.
یادآوری‌های **دوره‌ای** (بازدید و ثبت چک) کلیدشان شامل شمارهٔ دوره است. یعنی اگر
مدیر یک مورد را از صف حذف کند، فقط همان دوره کنسل می‌شود و در دورهٔ بعد دوباره
ساخته می‌شود؛ برای همیشه خاموش نمی‌ماند.
یادآوری‌های **دوره‌ای** (بازدید) کلیدشان شامل شمارهٔ دوره است. یعنی اگر مدیر یک مورد
را از صف حذف کند، فقط همان دوره کنسل می‌شود و در دورهٔ بعد دوباره ساخته می‌شود؛ برای
همیشه خاموش نمی‌ماند.
"""
import logging
import os
from datetime import datetime, time, timedelta
from django.db.models import Q, Sum
from django.utils import timezone
from ..models import (Article, Customer, Factor, Sarresid, SMSProviderConfig,
SMSTemplate, SMSType)
from ..models import Customer, Sarresid, SMSProviderConfig, SMSTemplate, SMSType
from .enqueue import enqueue_sms
from .timing import DEFAULT_VISIT_MONTHS
logger = logging.getLogger(__name__)
# پیش‌فرض فاصلهٔ تکرار «یادآوری ثبت چک» (روز) وقتی قالبی تنظیم نشده باشد.
CHEQUE_REG_INTERVAL_DEFAULT = 3
def _template(sms_type):
return SMSTemplate.objects.filter(sms_type=sms_type, is_enabled=True).first()
@@ -82,33 +80,39 @@ class _Budget:
self.new_count += 1
def scan_cheque_due(today):
template = _template(SMSType.CHEQUE_DUE)
def scan_cheque_funding(today):
"""یادآوری «تأمین موجودی چک» چند روز پس از سررسید چکِ پرداخت‌نشده.
چند روزِ پس از سررسید از قالب همین نوع (قابل تنظیم از پنل) خوانده می‌شود و مبلغ
چک در متن قرار می‌گیرد. اگر چک پرداخت شود (``payStatus=true``) دیگر یادآوری
نمی‌گیرد؛ ``dedup_key`` نیز مانع ارسال تکراری در اجراهای بعدی می‌شود. اگر اسکنر
چند روز از دست برود، چک‌های «معوق‌تر» هم واجد شرط می‌شوند تا یادآوری از دست نرود.
"""
template = _template(SMSType.CHEQUE_REGISTRATION)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
offset = template.offset_days or 0
target = today - timedelta(days=offset)
qs = Sarresid.objects.filter(
type="check", payStatus="false",
sarresidDT__date__gte=today,
sarresidDT__date__lte=today + timedelta(days=offset),
type="check", payStatus="false", sarresidDT__date__lte=target,
).select_related("customer").order_by("sarresidDT")
count = 0
for s in qs:
if not s.customer or not s.sarresidDT:
continue
# پنجرهٔ یادآوری از «سررسید منهای offset» باز می‌شود.
trigger = _aware(s.sarresidDT) - timedelta(days=offset)
# لحظهٔ باز شدن پنجرهٔ یادآوری = سررسید + offset روز.
trigger = _aware(s.sarresidDT) + timedelta(days=offset)
if not budget.can_send(trigger):
continue
days = (s.sarresidDT.date() - today).days
days = (today - s.sarresidDT.date()).days
made = enqueue_sms(
customer=s.customer, sms_type=SMSType.CHEQUE_DUE,
customer=s.customer, sms_type=SMSType.CHEQUE_REGISTRATION,
context={
"name": s.customer.name_family, "amount": s.price,
"date": s.sarresidDT, "days": days, "check_row": s.check_row_number,
},
dedup_key=f"cheque_due:{s.id}",
dedup_key=f"cheque_fund:{s.id}",
)
if made:
budget.record(trigger)
@@ -149,119 +153,6 @@ def scan_nesiye_due(today):
return count
def scan_cheque_registration(today):
"""یادآوری «ثبت اطلاعات چک» برای چک‌های پرداخت‌نشدهٔ ثبت‌نشده.
هر ``interval`` روز یک‌بار تکرار می‌شود و به‌محض اینکه شمارهٔ صیادی چک ثبت شود
(یا چک پرداخت شود) متوقف می‌گردد. دورهٔ تکرار از پنل قابل تنظیم است.
"""
template = _template(SMSType.CHEQUE_REGISTRATION)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
interval = template.repeat_interval_days or CHEQUE_REG_INTERVAL_DEFAULT
qs = Sarresid.objects.filter(
type="check", payStatus="false", sarresidDT__date__gte=today,
).filter(
Q(check_sayyad_id="") | Q(check_sayyad_id__isnull=True),
).select_related("customer")
count = 0
for s in qs:
if not s.customer:
continue
anchor = s.createdDT or s.sarresidDT
if not anchor:
continue
days = (today - anchor.date()).days
if days <= 0 or days % interval != 0:
continue
cycle = days // interval
# لحظهٔ باز شدن همین دورهٔ تکرار (نه خودِ چک) ملاک خط شروع است.
trigger = _aware(anchor) + timedelta(days=days)
if not budget.can_send(trigger):
continue
made = enqueue_sms(
customer=s.customer, sms_type=SMSType.CHEQUE_REGISTRATION,
context={
"name": s.customer.name_family, "amount": s.price,
"date": s.sarresidDT, "days": (s.sarresidDT.date() - today).days
if s.sarresidDT else "",
"check_row": s.check_row_number,
},
dedup_key=f"cheque_reg:{s.id}:{cycle}",
)
if made:
budget.record(trigger)
count += made
return count
def scan_debt_due(today):
"""یادآوری بدهی برای مانده‌دارها روی سررسید وصول مطالبات."""
template = _template(SMSType.DEBT_DUE)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
offset = template.offset_days or 0
target = today - timedelta(days=offset)
qs = Sarresid.objects.filter(
payStatus="false", sarresidDT__date=target,
).select_related("customer").order_by("sarresidDT")
count = 0
seen = set()
for s in qs:
if not s.customer or s.customer_id in seen or not s.sarresidDT:
continue
trigger = _aware(s.sarresidDT)
if not budget.can_send(trigger):
continue
totals = Article.objects.filter(customer=s.customer).aggregate(
d=Sum("debit"), c=Sum("credit"),
)
net = (totals["d"] or 0) - (totals["c"] or 0)
if net <= 0:
continue
made = enqueue_sms(
customer=s.customer, sms_type=SMSType.DEBT_DUE,
context={"name": s.customer.name_family, "amount": net},
dedup_key=f"debt_due:{s.id}",
)
if made:
seen.add(s.customer_id)
budget.record(trigger)
count += made
return count
def scan_survey(today):
"""نظرسنجی چند روز پس از ایجاد فاکتور."""
template = _template(SMSType.SURVEY)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
offset = template.offset_days or 3
target = today - timedelta(days=offset)
qs = Factor.objects.filter(
is_active=True, create_date__date=target,
).exclude(pay_status="cancell").select_related("customer").order_by("create_date")
count = 0
for f in qs:
if not f.customer or not f.create_date:
continue
trigger = _aware(f.create_date) + timedelta(days=offset)
if not budget.can_send(trigger):
continue
made = enqueue_sms(
customer=f.customer, sms_type=SMSType.SURVEY,
context={"name": f.customer.name_family, "amount": f.amount},
dedup_key=f"survey:{f.id}",
)
if made:
budget.record(trigger)
count += made
return count
def scan_visit(today):
"""یادآوری بازدید برای مشتریان عقب‌افتاده از بازدید (منطق صفحهٔ «انتظار»).
@@ -313,12 +204,11 @@ def scan_visit(today):
return count
# فقط همان ۷ نوع مجاز؛ انواع رویدادی (خوش‌آمدگویی، نظرسنجی، ثبت بازدید، تشکر از
# پرداخت) در ``admin_panel/sms/triggers.py`` توسط ویوها صف می‌شوند، نه این‌جا.
SCANNERS = [
("cheque_due", scan_cheque_due),
("cheque_registration", scan_cheque_funding),
("nesiye_due", scan_nesiye_due),
("cheque_registration", scan_cheque_registration),
("debt_due", scan_debt_due),
("survey", scan_survey),
("visit", scan_visit),
]
+24 -34
View File
@@ -9,8 +9,7 @@ from ..models import SMSType
NONE = "none"
BEFORE_DUE = "before_due"
AFTER_CREATION = "after_creation"
RECURRING = "recurring"
AFTER_DUE = "after_due"
PERIOD_MONTHS = "period_months"
# پیش‌فرض دورهٔ بازدید (ماه) وقتی مدیر مقداری در قالب نگذاشته باشد.
@@ -22,39 +21,18 @@ TIMING = {
"field": None,
"note": "به‌محض ثبت مشتری و بدون تأخیر ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.CHEQUE_DUE: {
"kind": BEFORE_DUE,
"field": "offset_days",
"label": "چند روز قبل از سررسید چک",
"default": 2,
"note": "پنجرهٔ یادآوری از «سررسید − این تعداد روز» باز می‌شود و تا سررسید ادامه دارد.",
},
SMSType.NESIYE_DUE: {
"kind": BEFORE_DUE,
"field": "offset_days",
"label": "چند روز قبل از سررسید نسیه",
"default": 2,
"note": "پنجرهٔ یادآوری از «سررسید − این تعداد روز» باز می‌شود و تا سررسید ادامه دارد.",
},
SMSType.CHEQUE_REGISTRATION: {
"kind": RECURRING,
"field": "repeat_interval_days",
"label": "هر چند روز یک‌بار",
"default": 3,
"note": "تا زمانی که اطلاعات ثبت چک (شمارهٔ صیادی) وارد نشده و چک پرداخت نشده، "
"با این فاصله تکرار می‌شود.",
},
SMSType.SURVEY: {
"kind": AFTER_CREATION,
"field": "offset_days",
"label": "چند روز بعد از صدور فاکتور",
"default": 3,
"note": "این تعداد روز پس از ثبت فاکتور ارسال می‌شود.",
},
SMSType.DEBT_DUE: {
"kind": NONE,
"field": None,
"note": "روی سررسیدِ وصول مطالبات و فقط برای مشتریانی که مانده بدهکارند ارسال می‌شود.",
"note": "بی‌درنگ پس از ثبت فاکتور ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.CHEQUE_REGISTRATION: {
"kind": AFTER_DUE,
"field": "offset_days",
"label": "چند روز پس از سررسید چک",
"default": 1,
"note": "این تعداد روز پس از سررسید چکِ پرداخت‌نشده، پیامک یادآوری "
"تأمین موجودی با مبلغ چک ارسال می‌شود.",
},
SMSType.VISIT: {
"kind": PERIOD_MONTHS,
@@ -63,10 +41,22 @@ TIMING = {
"default": DEFAULT_VISIT_MONTHS,
"note": "برای مشتریانی که از این مدت بازدید نشده‌اند ارسال می‌شود.",
},
SMSType.CUSTOM: {
SMSType.VISIT_CREATED: {
"kind": NONE,
"field": None,
"note": "فقط به‌صورت دستی از صفحهٔ مشتری ارسال می‌شود؛ تنظیم زمانی ندارد.",
"note": "به‌محض ثبت «اقدام از نوع بازدید» ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.PAYMENT_THANKS: {
"kind": NONE,
"field": None,
"note": "بی‌درنگ پس از ثبت پرداخت (سند وصول/بستانکار) ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.NESIYE_DUE: {
"kind": BEFORE_DUE,
"field": "offset_days",
"label": "چند روز قبل از سررسید نسیه",
"default": 0,
"note": "پنجرهٔ یادآوری از «سررسید − این تعداد روز» باز می‌شود و تا سررسید ادامه دارد.",
},
}
+82
View File
@@ -0,0 +1,82 @@
"""تریگرهای رویدادی پیامک (لایهٔ سرویس، بدون signal).
چهار نوع پیامک «رویدادی» هستند و لحظهٔ وقوع‌شان یک اکشن پنل است، نه یک سررسید
تاریخی؛ پس به‌جای اسکنر دوره‌ای، ویوها بعد از انجام کارِ اصلی این توابع را صدا
می‌زنند:
* :func:`notify_welcome` — ایجاد مشتری.
* :func:`notify_factor_survey` — ثبت فاکتور (نظرسنجی).
* :func:`notify_visit_scheduled` — ثبت «اقدام از نوع بازدید».
* :func:`notify_payment_thanks` — ثبت پرداخت (سند بستانکار / وصول).
قرارداد پروژه: استفاده از signal ممنوع است؛ همه‌چیز صریح از دلِ ویو صدا زده می‌شود.
این توابع هیچ استثنایی به فراخوان پرتاب نمی‌کنند (``enqueue_sms`` خودش مقاوم است) تا
مسیر اصلی هرگز به‌خاطر پیامک مختل نشود.
"""
import logging
from ..models import SMSType
from .enqueue import enqueue_sms
logger = logging.getLogger(__name__)
# روش‌های «غیرپرداخت»: سند بستانکاری که به‌خاطر تخفیف صادر شده، پرداختِ واقعی نیست
# و نباید پیامک «تشکر از پرداخت» بگیرد.
NON_PAYMENT_METHODS = {"تخفیف"}
def notify_welcome(customer, *, created_by=None):
"""پیامک خوش‌آمدگویی در لحظهٔ ساخت مشتری."""
if customer is None or customer.pk is None:
return 0
return enqueue_sms(
customer=customer, sms_type=SMSType.WELCOME,
context={"name": customer.name_family},
dedup_key=f"welcome:{customer.pk}",
created_by=created_by,
)
def notify_factor_survey(customer, factor, *, created_by=None):
"""پیامک نظرسنجی، بی‌درنگ پس از ثبت فاکتور."""
if customer is None or factor is None or factor.pk is None:
return 0
return enqueue_sms(
customer=customer, sms_type=SMSType.SURVEY,
context={"name": customer.name_family, "amount": factor.amount},
dedup_key=f"survey:{factor.pk}",
created_by=created_by,
)
def notify_visit_scheduled(customer, action, *, created_by=None):
"""پیامک «بازدید شما برای تاریخ … ثبت شد» پس از ایجاد اقدام بازدید."""
if customer is None or action is None or action.pk is None:
return 0
return enqueue_sms(
customer=customer, sms_type=SMSType.VISIT_CREATED,
context={"name": customer.name_family, "date": action.actionDateTime},
dedup_key=f"visit_created:{action.pk}",
created_by=created_by,
)
def notify_payment_thanks(customer, amount, *, article_id=None, method=None,
created_by=None):
"""پیامک تشکر از پرداخت؛ پس از ثبت سند پرداخت (بستانکار/وصول).
``article_id`` برای یکتاسازی استفاده می‌شود تا یک پرداخت هرگز دو پیامک نگیرد.
``method`` نحوهٔ پرداخت است؛ اگر «تخفیف» باشد (که پرداختِ واقعی نیست) هیچ پیامکی
ساخته نمی‌شود.
"""
if customer is None:
return 0
if method and str(method).strip() in NON_PAYMENT_METHODS:
return 0
dedup_key = f"payment_thanks:{article_id}" if article_id is not None else None
return enqueue_sms(
customer=customer, sms_type=SMSType.PAYMENT_THANKS,
context={"name": customer.name_family, "amount": amount},
dedup_key=dedup_key,
created_by=created_by,
)