improve entezar bazdid

This commit is contained in:
javad committed 2026-10-20 19:02:34 +03:30
1 parent 1fd27d0445
commit 2aee85593f
18 files changed
+10271 -7558

No files matched your search

+77 -33
View File
@@ -21,14 +21,14 @@
همیشه خاموش نمی‌ماند.
"""
import logging
import os
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
from .timing import DEFAULT_VISIT_MONTHS
logger = logging.getLogger(__name__)
@@ -38,24 +38,39 @@ def _template(sms_type):
def _aware(value):
"""تبدیل تاریخ/ساعت naive به aware (کد قدیمی پروژه جاهایی مقدار naive ذخیره می‌کند)."""
"""تبدیل تاریخ/ساعت naive به aware (کد قدیمی پروژه جاهایی مقدار naive ذخیره می‌کند).
در روزهای تغییر ساعت، نیمه‌شب ممکن است وجود نداشته باشد؛ آن حالت هم پوشش داده
می‌شود تا یک ``installDate`` روی مرز DST اجرای کل اسکن را نشکند.
"""
if value is None:
return None
if isinstance(value, datetime):
if timezone.is_naive(value):
return timezone.make_aware(value, timezone.get_current_timezone())
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 → آغاز همان روز
return timezone.make_aware(
datetime.combine(value, time.min), timezone.get_current_timezone(),
)
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):
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
@@ -63,8 +78,21 @@ class _Budget:
self.backlog_count = 0
def is_backlog(self, trigger):
"""آیا این مورد پیش از «خط شروع» واجد شرط شده است؟
مرجع زمانی، «اکنونِ همین اجرا» است (``self.now``)؛ برای اسکن عادی همان
``timezone.now()`` و برای اسکن با زمان تزریقی همان لحظهٔ تزریق‌شده.
"""
trigger = _aware(trigger)
return self.since is not None and trigger < self.since
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:
@@ -166,50 +194,66 @@ def scan_nesiye_due(today):
return count
def scan_visit(today):
def scan_visit(today, *, now=None):
"""یادآوری بازدید برای مشتریان عقب‌افتاده از بازدید (منطق صفحهٔ «انتظار»).
لحظهٔ واجد‌شرط‌شدن هر مشتری = «آخرین بازدید + دورهٔ مجاز» (یا تاریخ نصب برای
مشتریانی که هرگز بازدید نشده‌اند). اگر این لحظه پیش از خط شروع باشد، مورد قدیمی
تلقی می‌شود و فقط با اجازهٔ صریح مدیر (و در سقف تعیین‌شده) ارسال می‌گردد.
لحظهٔ واجد‌شرط‌شدن هر مشتری = «آخرین بازدید معتبر + دورهٔ مجاز» (یا تاریخ نصب
برای مشتریانی که هرگز بازدید نشده‌اند). مبنای «آخرین بازدید» و دوره، هر دو از
``admin_panel.visit_policy`` می‌آیند تا این اسکنر و صفحهٔ ``entezar`` هرگز
دو جواب متفاوت ندهند. مشتری دارای «تعویق فعال» (هماهنگی در انتظار) یادآوری
نمی‌گیرد، چون قرار بعدی از قبل با او گذاشته شده است.
``now`` برای تست/بازپخش قابل تزریق است؛ پیش‌فرض «همین حالا» است.
اگر لحظهٔ واجد‌شرط‌شدن پیش از خط شروع باشد، مورد قدیمی تلقی می‌شود و فقط با
اجازهٔ صریح مدیر (و در سقف تعیین‌شده) ارسال می‌گردد.
"""
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()
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=(now - period).date(),
installDate__lte=cutoff_date,
).order_by("id")
count = 0
for customer in qs:
anchor = _aware(customer.last_bazdid_dt) or _aware(customer.installDate)
if customer.id in appointments:
continue # قرار بعدی ثبت شده است؛ یادآوری لازم نیست
anchor = anchors.get(customer.id) or _aware(customer.installDate)
if anchor is None:
continue
trigger = anchor + period
if trigger > now:
# «تازه‌ترین» موعدی که رسیده است (۹ ماه، ۱۸ ماه، ۲۷ ماه…).
# قاعدهٔ قبلی همیشه موعد اول را می‌سنجید و همین باعث می‌شد دوره‌های بعدی
# هرگز ساخته نشوند.
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
# چرخه = شمارهٔ دورهٔ کاملِ سپری‌شده از لنگر. کلید به چرخه گره می‌خورد تا
# حذفِ یادآوری توسط مدیر فقط همان «دوره» را کنسل کند و در دوره‌های بعدی
# دوباره ساخته شود (نه اینکه برای همیشه خاموش بماند).
cycle = max((today - anchor.date()).days // period_days, 0)
# کلید یکتاسازی = «موعد تقویمیِ» همان دوره، نه شمارش روز.
# با این قاعده هر دوره دقیقاً یک کلید دارد: ۹ ماه، ۱۸ ماه، ۲۷ ماه… .
# (قاعدهٔ قبلی — 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}:{cycle}",
dedup_key=f"visit:{customer.id}:{due_month}",
)
if made:
budget.record(trigger)