337 lines
13 KiB
Python
337 lines
13 KiB
Python
"""اسکنر دورهای: پیدا کردن موارد سررسیدشده و قرار دادن پیامک در صف.
|
|
|
|
قواعد کنترل جریان (همه از پنل مدیریت قابل تنظیم):
|
|
|
|
* **خط شروع (``scan_since``)**: فقط مواردی صف میشوند که «لحظهٔ باز شدن پنجرهٔ یادآوری»
|
|
آنها از این لحظه به بعد باشد. بنابراین در نخستین اجرا هیچکدام از موارد قدیمی موجود
|
|
در دیتابیس یکباره ارسال نمیشوند.
|
|
* **اجازهٔ موارد قدیمی (``allow_backlog`` + ``backlog_max``)**: اگر مدیر صریحاً روشن کند،
|
|
در هر اجرا حداکثر ``backlog_max`` مورد قدیمی هم صف میشود.
|
|
* **سقف هر اجرا (``max_per_run``)**: سقف کل صفکردن هر اسکنر در یک اجرا.
|
|
|
|
هر اسکنر مستقل و مقاوم به خطاست؛ یک خطا بقیه را متوقف نمیکند. ``dedup_key`` از
|
|
ارسال تکراری جلوگیری میکند و تضمین میکند اجرای دوبارهٔ اسکنر رکورد جدیدی نسازد.
|
|
|
|
یادآوریهای **دورهای** (بازدید و ثبت چک) کلیدشان شامل شمارهٔ دوره است. یعنی اگر
|
|
مدیر یک مورد را از صف حذف کند، فقط همان دوره کنسل میشود و در دورهٔ بعد دوباره
|
|
ساخته میشود؛ برای همیشه خاموش نمیماند.
|
|
"""
|
|
import logging
|
|
import os
|
|
from datetime import datetime, time, timedelta
|
|
|
|
from django.db.models import Q, Sum
|
|
from django.utils import timezone
|
|
|
|
from ..models import (Article, Customer, Factor, Sarresid, SMSProviderConfig,
|
|
SMSTemplate, SMSType)
|
|
from .enqueue import enqueue_sms
|
|
from .timing import DEFAULT_VISIT_MONTHS
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# پیشفرض فاصلهٔ تکرار «یادآوری ثبت چک» (روز) وقتی قالبی تنظیم نشده باشد.
|
|
CHEQUE_REG_INTERVAL_DEFAULT = 3
|
|
|
|
|
|
|
|
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_due(today):
|
|
template = _template(SMSType.CHEQUE_DUE)
|
|
if template is None:
|
|
return 0
|
|
budget = _Budget(SMSProviderConfig.load())
|
|
offset = template.offset_days or 0
|
|
qs = Sarresid.objects.filter(
|
|
type="check", 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
|
|
# پنجرهٔ یادآوری از «سررسید منهای offset» باز میشود.
|
|
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.CHEQUE_DUE,
|
|
context={
|
|
"name": s.customer.name_family, "amount": s.price,
|
|
"date": s.sarresidDT, "days": days, "check_row": s.check_row_number,
|
|
},
|
|
dedup_key=f"cheque_due:{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_cheque_registration(today):
|
|
"""یادآوری «ثبت اطلاعات چک» برای چکهای پرداختنشدهٔ ثبتنشده.
|
|
|
|
هر ``interval`` روز یکبار تکرار میشود و بهمحض اینکه شمارهٔ صیادی چک ثبت شود
|
|
(یا چک پرداخت شود) متوقف میگردد. دورهٔ تکرار از پنل قابل تنظیم است.
|
|
"""
|
|
template = _template(SMSType.CHEQUE_REGISTRATION)
|
|
if template is None:
|
|
return 0
|
|
budget = _Budget(SMSProviderConfig.load())
|
|
interval = template.repeat_interval_days or CHEQUE_REG_INTERVAL_DEFAULT
|
|
qs = Sarresid.objects.filter(
|
|
type="check", payStatus="false", sarresidDT__date__gte=today,
|
|
).filter(
|
|
Q(check_sayyad_id="") | Q(check_sayyad_id__isnull=True),
|
|
).select_related("customer")
|
|
count = 0
|
|
for s in qs:
|
|
if not s.customer:
|
|
continue
|
|
anchor = s.createdDT or s.sarresidDT
|
|
if not anchor:
|
|
continue
|
|
days = (today - anchor.date()).days
|
|
if days <= 0 or days % interval != 0:
|
|
continue
|
|
cycle = days // interval
|
|
# لحظهٔ باز شدن همین دورهٔ تکرار (نه خودِ چک) ملاک خط شروع است.
|
|
trigger = _aware(anchor) + timedelta(days=days)
|
|
if not budget.can_send(trigger):
|
|
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": (s.sarresidDT.date() - today).days
|
|
if s.sarresidDT else "",
|
|
"check_row": s.check_row_number,
|
|
},
|
|
dedup_key=f"cheque_reg:{s.id}:{cycle}",
|
|
)
|
|
if made:
|
|
budget.record(trigger)
|
|
count += made
|
|
return count
|
|
|
|
|
|
def scan_debt_due(today):
|
|
"""یادآوری بدهی برای ماندهدارها روی سررسید وصول مطالبات."""
|
|
template = _template(SMSType.DEBT_DUE)
|
|
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(
|
|
payStatus="false", sarresidDT__date=target,
|
|
).select_related("customer").order_by("sarresidDT")
|
|
count = 0
|
|
seen = set()
|
|
for s in qs:
|
|
if not s.customer or s.customer_id in seen or not s.sarresidDT:
|
|
continue
|
|
trigger = _aware(s.sarresidDT)
|
|
if not budget.can_send(trigger):
|
|
continue
|
|
totals = Article.objects.filter(customer=s.customer).aggregate(
|
|
d=Sum("debit"), c=Sum("credit"),
|
|
)
|
|
net = (totals["d"] or 0) - (totals["c"] or 0)
|
|
if net <= 0:
|
|
continue
|
|
made = enqueue_sms(
|
|
customer=s.customer, sms_type=SMSType.DEBT_DUE,
|
|
context={"name": s.customer.name_family, "amount": net},
|
|
dedup_key=f"debt_due:{s.id}",
|
|
)
|
|
if made:
|
|
seen.add(s.customer_id)
|
|
budget.record(trigger)
|
|
count += made
|
|
return count
|
|
|
|
|
|
def scan_survey(today):
|
|
"""نظرسنجی چند روز پس از ایجاد فاکتور."""
|
|
template = _template(SMSType.SURVEY)
|
|
if template is None:
|
|
return 0
|
|
budget = _Budget(SMSProviderConfig.load())
|
|
offset = template.offset_days or 3
|
|
target = today - timedelta(days=offset)
|
|
qs = Factor.objects.filter(
|
|
is_active=True, create_date__date=target,
|
|
).exclude(pay_status="cancell").select_related("customer").order_by("create_date")
|
|
count = 0
|
|
for f in qs:
|
|
if not f.customer or not f.create_date:
|
|
continue
|
|
trigger = _aware(f.create_date) + timedelta(days=offset)
|
|
if not budget.can_send(trigger):
|
|
continue
|
|
made = enqueue_sms(
|
|
customer=f.customer, sms_type=SMSType.SURVEY,
|
|
context={"name": f.customer.name_family, "amount": f.amount},
|
|
dedup_key=f"survey:{f.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
|
|
|
|
|
|
SCANNERS = [
|
|
("cheque_due", scan_cheque_due),
|
|
("nesiye_due", scan_nesiye_due),
|
|
("cheque_registration", scan_cheque_registration),
|
|
("debt_due", scan_debt_due),
|
|
("survey", scan_survey),
|
|
("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
|