"""اسکنر دوره‌ای: پیدا کردن موارد سررسیدشده و قرار دادن پیامک در صف. قواعد کنترل جریان (همه از پنل مدیریت قابل تنظیم): * **خط شروع (``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): """یادآوری «تأمین موجودی چک» چند روز پس از سررسید چکِ پرداخت‌نشده. چند روزِ پس از سررسید از قالب همین نوع (قابل تنظیم از پنل) خوانده می‌شود و مبلغ چک در متن قرار می‌گیرد. اگر چک پرداخت شود (``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__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) if not budget.can_send(trigger): continue days = (today - s.sarresidDT.date()).days 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, }, 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