Files
khatibi/admin_panel/sms/scanner.py
T
2026-10-27 11:49:09 +03:30

285 lines
15 KiB
Python

"""اسکنر دوره‌ای: پیدا کردن موارد سررسیدشده و قرار دادن پیامک در صف.
قواعد کنترل جریان (همه از پنل مدیریت قابل تنظیم):
* **خط شروع (``scan_since``)**: فقط مواردی صف می‌شوند که «لحظهٔ باز شدن پنجرهٔ یادآوری»
آن‌ها از این لحظه به بعد باشد. بنابراین در نخستین اجرا هیچ‌کدام از موارد قدیمی موجود
در دیتابیس یک‌باره ارسال نمی‌شوند.
* **اجازهٔ موارد قدیمی (``allow_backlog`` + ``backlog_max``)**: اگر مدیر صریحاً روشن کند،
در هر اجرا حداکثر ``backlog_max`` مورد قدیمی هم صف می‌شود.
* **سقف هر اجرا (``max_per_run``)**: سقف کل صف‌کردن هر اسکنر در یک اجرا.
اسکنرها فقط انواع «دوره‌ای» را پوشش می‌دهند (تأمین موجودی چک، سررسید نسیه، بازدید
دوره‌ای). انواع «رویدادی» (خوش‌آمدگویی، نظرسنجی، ثبت بازدید، تشکر از پرداخت) هنگام
وقوع رخداد توسط ویوها و از طریق ``admin_panel/sms/triggers.py`` صف می‌شوند.
هر اسکنر مستقل و مقاوم به خطاست؛ یک خطا بقیه را متوقف نمی‌کند. ``dedup_key`` از
ارسال تکراری جلوگیری می‌کند و تضمین می‌کند اجرای دوبارهٔ اسکنر رکورد جدیدی نسازد.
یادآوری‌های **دوره‌ای** (بازدید) کلیدشان شامل شمارهٔ دوره است. یعنی اگر مدیر یک مورد
را از صف حذف کند، فقط همان دوره کنسل می‌شود و در دورهٔ بعد دوباره ساخته می‌شود؛ برای
همیشه خاموش نمی‌ماند.
"""
import logging
from datetime import datetime, time, timedelta
from django.utils import timezone
from ..models import Customer, Sarresid, SMSProviderConfig, SMSTemplate, SMSType
from ..visit_policy import (as_aware, cycle_anchor_map, next_appointment_map,
visit_latest_due, visit_period)
from .enqueue import enqueue_sms
logger = logging.getLogger(__name__)
def _template(sms_type):
return SMSTemplate.objects.filter(sms_type=sms_type, is_enabled=True).first()
def _aware(value):
"""تبدیل تاریخ/ساعت naive به aware (کد قدیمی پروژه جاهایی مقدار naive ذخیره می‌کند).
در روزهای تغییر ساعت، نیمه‌شب ممکن است وجود نداشته باشد؛ آن حالت هم پوشش داده
می‌شود تا یک ``installDate`` روی مرز DST اجرای کل اسکن را نشکند.
"""
if value is None:
return None
if isinstance(value, datetime):
if timezone.is_naive(value):
try:
return timezone.make_aware(value, timezone.get_current_timezone())
except Exception:
return timezone.make_aware(
value, timezone.get_current_timezone(), is_dst=False,
)
return value
# date → آغاز همان روز
moment = datetime.combine(value, time.min)
try:
return timezone.make_aware(moment, timezone.get_current_timezone())
except Exception:
return timezone.make_aware(
moment, timezone.get_current_timezone(), is_dst=False,
)
class _Budget:
"""ظرفیت صف‌کردن یک اسکنر در یک اجرا (خط شروع + سقف قدیمی + سقف کل)."""
def __init__(self, config, now=None):
self.since = config.scan_since
# مرجع زمانی «خط شروع»؛ برای اسکن با زمان تزریقی (تست/بازپخش) هم‌زمان می‌شود.
self.now = now
self.allow_backlog = bool(config.allow_backlog)
self.backlog_max = config.backlog_max or 0
self.max_per_run = config.max_per_run or 0
self.new_count = 0
self.backlog_count = 0
def is_backlog(self, trigger):
"""آیا این مورد پیش از «خط شروع» واجد شرط شده است؟
مرجع زمانی، «اکنونِ همین اجرا» است (``self.now``)؛ برای اسکن عادی همان
``timezone.now()`` و برای اسکن با زمان تزریقی همان لحظهٔ تزریق‌شده.
"""
trigger = _aware(trigger)
since = self.since
if since is not None and self.now is not None:
moment = _aware(self.now)
now_ref = timezone.now()
if moment is not None and now_ref is not None:
# پنجره با «همان لحظه‌ای» که اسکن برایش اجرا می‌شود سنجیده شود،
# وگرنه اسکنِ بازپخش/تست همیشه همه‌چیز را «قدیمی» می‌بیند.
since = since + (moment - now_ref)
return since is not None and trigger < since
def can_send(self, trigger):
if self.max_per_run and (self.new_count + self.backlog_count) >= self.max_per_run:
return False
if self.is_backlog(trigger):
return self.allow_backlog and self.backlog_count < self.backlog_max
return True
def record(self, trigger):
if self.is_backlog(trigger):
self.backlog_count += 1
else:
self.new_count += 1
def scan_cheque_funding(today):
"""یادآوری «تأمین موجودی چک» چند روز **پیش از** سررسید چکِ پرداخت‌نشده.
تعداد روزِ *قبل* از سررسید از قالب همین نوع (``offset_days`` در دیتابیس،
قابل ویرایش از پنل) خوانده می‌شود؛ مثلاً ``offset_days = 1`` یعنی یک روز پیش
از سررسید پیامک می‌رود تا مشتری فرصت تأمین موجودی داشته باشد.
پنجرهٔ یادآوری از «سررسید − offset روز» باز می‌شود و تا خودِ سررسید ادامه
دارد؛ پس اگر اسکنر چند روز از دست برود، یادآوری از دست نمی‌رود (چک‌هایی که
سررسیدشان رسیده هم واجد شرط می‌مانند). اگر چک پرداخت شود
(``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
# پنجرهٔ یادآوری: از «سررسید − offset» تا «سررسید». یعنی فقط سررسیدهای
# امروز تا offset روز آینده واجد شرط‌اند و مقادیر دورتر هنوز پیامکی نمی‌گیرند.
# (سررسیدهای گذشته هم داخل شرط می‌مانند تا یک یادآوری از دست نرود.)
window_end = today + timedelta(days=max(offset, 0))
qs = Sarresid.objects.filter(
type="check", payStatus="false", sarresidDT__date__lte=window_end,
).select_related("customer").order_by("sarresidDT")
count = 0
for s in qs:
if not s.customer or not s.sarresidDT:
continue
due = s.sarresidDT.date()
# لحظهٔ باز شدن پنجرهٔ یادآوری = سررسید − offset روز.
trigger = _aware(s.sarresidDT) - timedelta(days=offset)
if not budget.can_send(trigger):
continue
# روزهای باقی‌مانده تا سررسید؛ اگر سررسید دورتر از پنجره باشد پیامک نمی‌رود.
days = (due - today).days
if days > offset:
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": days, "check_row": s.check_row_number,
"sayyad_id": s.check_sayyad_id, "owner_name": s.check_owner_name,
},
dedup_key=f"cheque_fund:{s.id}",
)
if made:
budget.record(trigger)
count += made
return count
def scan_nesiye_due(today):
template = _template(SMSType.NESIYE_DUE)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
offset = template.offset_days or 0
qs = Sarresid.objects.filter(
type="nesiye", payStatus="false",
sarresidDT__date__gte=today,
sarresidDT__date__lte=today + timedelta(days=offset),
).select_related("customer").order_by("sarresidDT")
count = 0
for s in qs:
if not s.customer or not s.sarresidDT:
continue
trigger = _aware(s.sarresidDT) - timedelta(days=offset)
if not budget.can_send(trigger):
continue
days = (s.sarresidDT.date() - today).days
made = enqueue_sms(
customer=s.customer, sms_type=SMSType.NESIYE_DUE,
context={
"name": s.customer.name_family, "amount": s.price,
"date": s.sarresidDT, "days": days,
},
dedup_key=f"nesiye_due:{s.id}",
)
if made:
budget.record(trigger)
count += made
return count
def scan_visit(today, *, now=None):
"""یادآوری بازدید برای مشتریان عقب‌افتاده از بازدید (منطق صفحهٔ «انتظار»).
لحظهٔ واجد‌شرط‌شدن هر مشتری = «آخرین بازدید معتبر + دورهٔ مجاز» (یا تاریخ نصب
برای مشتریانی که هرگز بازدید نشده‌اند). مبنای «آخرین بازدید» و دوره، هر دو از
``admin_panel.visit_policy`` می‌آیند تا این اسکنر و صفحهٔ ``entezar`` هرگز
دو جواب متفاوت ندهند. مشتری دارای «تعویق فعال» (هماهنگی در انتظار) یادآوری
نمی‌گیرد، چون قرار بعدی از قبل با او گذاشته شده است.
``now`` برای تست/بازپخش قابل تزریق است؛ پیش‌فرض «همین حالا» است.
اگر لحظهٔ واجد‌شرط‌شدن پیش از خط شروع باشد، مورد قدیمی تلقی می‌شود و فقط با
اجازهٔ صریح مدیر (و در سقف تعیین‌شده) ارسال می‌گردد.
"""
template = _template(SMSType.VISIT)
if template is None:
return 0
period = visit_period()
now = timezone.now() if now is None else as_aware(now)
budget = _Budget(SMSProviderConfig.load(), now=now)
# لنگر چرخه: آخرین «بازدید» یا «نصب» (تصمیم محصولی). تماس تلفنی چرخه را ریست نمی‌کند.
anchors = cycle_anchor_map(now=now)
# قرار بعدی (هماهنگی توافق‌شده یا بازدید برنامه‌ریزی‌شده) ⇒ یادآوری لازم نیست.
appointments = next_appointment_map(now=now)
# مرز تاریخ‌ها باید به وقت **محلی** گرفته شود؛ `.date()` روی دیتاتایم aware،
# تاریخ UTC می‌دهد که در ساعت‌های اول شب تهران یک روز عقب‌تر است و باعث می‌شد
# مشتریِ دقیقاً روی مرز، از اسکن جا بیفتد.
cutoff_date = (now - period).astimezone(timezone.get_current_timezone()).date()
qs = Customer.objects.filter(
laghv_bazdid=False,
is_active=True,
installDate__lte=cutoff_date,
).order_by("id")
count = 0
for customer in qs:
if customer.id in appointments:
continue # قرار بعدی ثبت شده است؛ یادآوری لازم نیست
anchor = anchors.get(customer.id) or _aware(customer.installDate)
if anchor is None:
continue
# «تازه‌ترین» موعدی که رسیده است (۹ ماه، ۱۸ ماه، ۲۷ ماه…).
# قاعدهٔ قبلی همیشه موعد اول را می‌سنجید و همین باعث می‌شد دوره‌های بعدی
# هرگز ساخته نشوند.
trigger = visit_latest_due(anchor, now=now)
# موعد یک «تاریخ» است نه یک لحظه؛ اگر روزِ موعد فرا رسیده باشد باید صف شود.
if trigger is None or trigger.date() > now.astimezone(
timezone.get_current_timezone()
).date():
continue # هنوز عقب‌افتاده نیست
if not budget.can_send(trigger):
continue
# کلید یکتاسازی = «موعد تقویمیِ» همان دوره، نه شمارش روز.
# با این قاعده هر دوره دقیقاً یک کلید دارد: ۹ ماه، ۱۸ ماه، ۲۷ ماه… .
# (قاعدهٔ قبلی — days // 274 — با موعد تقویمی هم‌تراز نبود و باعث
# گم‌شدنِ یک دوره و تکرار/پرش شمارهٔ چرخه می‌شد.)
due_month = trigger.strftime("%Y-%m")
made = enqueue_sms(
customer=customer, sms_type=SMSType.VISIT,
context={"name": customer.name_family},
dedup_key=f"visit:{customer.id}:{due_month}",
)
if made:
budget.record(trigger)
count += made
return count
# فقط انواعی که «موعد» دارند و دوره‌ای اسکن می‌شوند؛ انواع رویدادی (خوش‌آمدگویی،
# نظرسنجی، ثبت بازدید، ثبت نصب، تشکر از پرداخت) در ``admin_panel/sms/triggers.py``
# توسط ویوها صف می‌شوند، نه این‌جا.
SCANNERS = [
("cheque_registration", scan_cheque_funding),
("nesiye_due", scan_nesiye_due),
("visit", scan_visit),
]
def run_all(today=None):
"""همهٔ اسکنرها را اجرا و نگاشت «نام → تعداد درج‌شده» را برمی‌گرداند."""
today = today or timezone.localdate()
results = {}
for name, func in SCANNERS:
try:
results[name] = func(today)
except Exception:
logger.exception("اسکن پیامک شکست خورد: %s", name)
results[name] = -1
return results