This commit is contained in:
javad committed 2026-10-10 14:21:04 +03:30
1 parent 250cdbab7f
commit 5c6069c3ef
87 files changed
+29209 -3697

No files matched your search

+336
View File
@@ -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