sms
This commit is contained in:
1 parent
250cdbab7f
commit
5c6069c3ef
87 files changed
+29209
-3697
No files matched your search
@@ -0,0 +1,336 @@
|
||||
"""اسکنر دورهای: پیدا کردن موارد سررسیدشده و قرار دادن پیامک در صف.
|
||||
|
||||
قواعد کنترل جریان (همه از پنل مدیریت قابل تنظیم):
|
||||
|
||||
* **خط شروع (``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
|
||||
Reference in new issue
Block a user