improve entezar bazdid
This commit is contained in:
1 parent
1fd27d0445
commit
2aee85593f
18 files changed
+10271
-7558
No files matched your search
+77
-33
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user