240 lines
11 KiB
Python
240 lines
11 KiB
Python
"""اسکنر دورهای: پیدا کردن موارد سررسیدشده و قرار دادن پیامک در صف.
|
|
|
|
قواعد کنترل جریان (همه از پنل مدیریت قابل تنظیم):
|
|
|
|
* **خط شروع (``scan_since``)**: فقط مواردی صف میشوند که «لحظهٔ باز شدن پنجرهٔ یادآوری»
|
|
آنها از این لحظه به بعد باشد. بنابراین در نخستین اجرا هیچکدام از موارد قدیمی موجود
|
|
در دیتابیس یکباره ارسال نمیشوند.
|
|
* **اجازهٔ موارد قدیمی (``allow_backlog`` + ``backlog_max``)**: اگر مدیر صریحاً روشن کند،
|
|
در هر اجرا حداکثر ``backlog_max`` مورد قدیمی هم صف میشود.
|
|
* **سقف هر اجرا (``max_per_run``)**: سقف کل صفکردن هر اسکنر در یک اجرا.
|
|
|
|
اسکنرها فقط انواع «دورهای» را پوشش میدهند (تأمین موجودی چک، سررسید نسیه، بازدید
|
|
دورهای). انواع «رویدادی» (خوشآمدگویی، نظرسنجی، ثبت بازدید، تشکر از پرداخت) هنگام
|
|
وقوع رخداد توسط ویوها و از طریق ``admin_panel/sms/triggers.py`` صف میشوند.
|
|
|
|
هر اسکنر مستقل و مقاوم به خطاست؛ یک خطا بقیه را متوقف نمیکند. ``dedup_key`` از
|
|
ارسال تکراری جلوگیری میکند و تضمین میکند اجرای دوبارهٔ اسکنر رکورد جدیدی نسازد.
|
|
|
|
یادآوریهای **دورهای** (بازدید) کلیدشان شامل شمارهٔ دوره است. یعنی اگر مدیر یک مورد
|
|
را از صف حذف کند، فقط همان دوره کنسل میشود و در دورهٔ بعد دوباره ساخته میشود؛ برای
|
|
همیشه خاموش نمیماند.
|
|
"""
|
|
import logging
|
|
import os
|
|
from datetime import datetime, time, timedelta
|
|
|
|
from django.utils import timezone
|
|
|
|
from ..models import Customer, Sarresid, SMSProviderConfig, SMSTemplate, SMSType
|
|
from .enqueue import enqueue_sms
|
|
from .timing import DEFAULT_VISIT_MONTHS
|
|
|
|
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 ذخیره میکند)."""
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, datetime):
|
|
if timezone.is_naive(value):
|
|
return timezone.make_aware(value, timezone.get_current_timezone())
|
|
return value
|
|
# date → آغاز همان روز
|
|
return timezone.make_aware(
|
|
datetime.combine(value, time.min), timezone.get_current_timezone(),
|
|
)
|
|
|
|
|
|
class _Budget:
|
|
"""ظرفیت صفکردن یک اسکنر در یک اجرا (خط شروع + سقف قدیمی + سقف کل)."""
|
|
|
|
def __init__(self, config):
|
|
self.since = config.scan_since
|
|
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):
|
|
trigger = _aware(trigger)
|
|
return self.since is not None and trigger < self.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):
|
|
"""یادآوری بازدید برای مشتریان عقبافتاده از بازدید (منطق صفحهٔ «انتظار»).
|
|
|
|
لحظهٔ واجدشرطشدن هر مشتری = «آخرین بازدید + دورهٔ مجاز» (یا تاریخ نصب برای
|
|
مشتریانی که هرگز بازدید نشدهاند). اگر این لحظه پیش از خط شروع باشد، مورد قدیمی
|
|
تلقی میشود و فقط با اجازهٔ صریح مدیر (و در سقف تعیینشده) ارسال میگردد.
|
|
"""
|
|
template = _template(SMSType.VISIT)
|
|
if template is None:
|
|
return 0
|
|
budget = _Budget(SMSProviderConfig.load())
|
|
# دورهٔ بازدید: اول از قالب (قابل تنظیم از پنل)، وگرنه متغیر محیطی legacy، وگرنه پیشفرض.
|
|
months = template.repeat_interval_months
|
|
if not months:
|
|
try:
|
|
months = int(os.environ.get("bazdid", DEFAULT_VISIT_MONTHS))
|
|
except (TypeError, ValueError):
|
|
months = DEFAULT_VISIT_MONTHS
|
|
period = timedelta(weeks=int(months * 4.3))
|
|
period_days = max(period.days, 1)
|
|
now = timezone.now()
|
|
qs = Customer.objects.filter(
|
|
laghv_bazdid=False,
|
|
is_active=True,
|
|
installDate__lte=(now - period).date(),
|
|
).order_by("id")
|
|
count = 0
|
|
for customer in qs:
|
|
anchor = _aware(customer.last_bazdid_dt) or _aware(customer.installDate)
|
|
if anchor is None:
|
|
continue
|
|
trigger = anchor + period
|
|
if trigger > now:
|
|
continue # هنوز عقبافتاده نیست
|
|
if not budget.can_send(trigger):
|
|
continue
|
|
# چرخه = شمارهٔ دورهٔ کاملِ سپریشده از لنگر. کلید به چرخه گره میخورد تا
|
|
# حذفِ یادآوری توسط مدیر فقط همان «دوره» را کنسل کند و در دورههای بعدی
|
|
# دوباره ساخته شود (نه اینکه برای همیشه خاموش بماند).
|
|
cycle = max((today - anchor.date()).days // period_days, 0)
|
|
made = enqueue_sms(
|
|
customer=customer, sms_type=SMSType.VISIT,
|
|
context={"name": customer.name_family},
|
|
dedup_key=f"visit:{customer.id}:{cycle}",
|
|
)
|
|
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
|