284 lines
14 KiB
Python
284 lines
14 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
|