Files
khatibi/admin_panel/visit_policy.py
T
2026-10-20 19:02:34 +03:30

515 lines
25 KiB
Python

"""سیاست بازدید مشتری — تنها منبع حقیقت برای «آخرین بازدید واقعی».
هدف این ماژول پایان‌دادن به سه تعریف واگرا از «آخرین بازدید» است:
* ویوهای ``customerNewAction``/``customerEditAction`` که قبلاً مستقیماً
``last_bazdid_dt = finishDateTime`` می‌نوشتند (بدون بررسی ترتیب و بدون مبنا).
* صفحهٔ ``/panel/entezar/`` که فقط به ستون کش‌شدهٔ ``Customer.last_bazdid_dt`` تکیه می‌کرد.
* اسکنر پیامک (``admin_panel/sms/scanner.py``) که همان ستون را به‌عنوان لنگر می‌خواند.
قواعد ثابت این ماژول:
۱. فقط اقدام ``actionType == 'bazdid' AND status == 'finish'`` در محاسبه دیده می‌شود.
«هماهنگی» (``hamahangi``) و بقیهٔ انواع هرگز وارد
:func:`last_visit_dt` نمی‌شوند — نه امروز، نه در آینده. تعویق/هماهنگی مفهوم جداگانه‌ای
است و فقط از :func:`active_deferrals` خوانده می‌شود.
۲. تاریخ مؤثر هر رکورد = ``actionDateTime`` **اگر از حالا جلوتر نباشد**؛ وگرنه
``finishDateTime`` اگر آن هم از حالا جلوتر نباشد؛ وگرنه «تاریخ نامعتبر».
قاعدهٔ قدیمی «اگر finish کوچک‌تر از action بود، finish را بگیر» عمداً حذف شده است:
در دادهٔ واقعی، آن حالت همان جایی است که ``finishDateTime`` برابر زمان ثبت رکورد
است و «زمان ثبت» را جای «تاریخ بازدید» می‌گذاشت.
۳. همهٔ مقایسه‌ها با تاریخ aware انجام می‌شود؛ مقادیر naive موجود در دیتابیس
(بقایای کد قدیمی) با همان منطقهٔ زمانی پروژه aware می‌شوند.
"""
import logging
from datetime import date, datetime, time, timezone as dt_timezone
from dateutil import relativedelta
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.db.models import Case, DateTimeField, F, Max, Min, Value, When
from django.utils import timezone
from .models import Action, Customer, SMSTemplate
from .sms.timing import DEFAULT_VISIT_MONTHS
logger = logging.getLogger(__name__)
# دلیل نوشتن ``last_bazdid_dt`` (برای لاگ؛ با ستون‌های حسابرسی اختیاری هم‌خوان است).
REASON_CREATE = "create"
REASON_EDIT = "edit"
REASON_CANCEL = "cancel"
REASON_RECALC = "recalc"
REASON_MANUAL = "manual"
# سقف مجاز «تاریخ آینده» برای بازدید انجام‌شده در اعتبارسنجی HTTP.
FINISH_MAX_FUTURE_DAYS = 30
# فقط این نوع از اقدام‌ها بازدید هستند (ثابت مدل، نه رشتهٔ خام).
_VISIT_TYPE = Action.ActionType.BAZDID
_VISIT_DONE = Action.Status.FINISH
# ---------------------------------------------------------------------------
# تبدیل تاریخ
# ---------------------------------------------------------------------------
def as_aware(value):
"""تاریخ/دیتاتایم naive یا ``date`` را به ``datetime`` aware تبدیل می‌کند.
برای مقادیر ``date``، «آغاز روز» استفاده می‌شود. در روزهای تغییر ساعت
(مثلاً ۲۲ مارس در تهران) نیمه‌شب ممکن است وجود نداشته باشد؛ در آن حالت
``is_dst=None`` خطای ``NonExistentTimeError`` می‌دهد، پس با ``is_dst=False``
ذخیره می‌شود تا هیچ‌گاه یک تاریخِ درست، اجرای اسکن/گزارش را نشکند.
"""
if value is None:
return None
if isinstance(value, datetime):
if timezone.is_naive(value):
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
if isinstance(value, date):
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,
)
return None
def _now(now=None):
return as_aware(now) if now is not None else timezone.now()
def effective_visit_dt(action, *, now=None):
"""تاریخ مؤثر یک اقدام بازدید انجام‌شده (یا ``None`` اگر تاریخ معتبری ندارد).
قاعده در docstring ماژول آمده است. رکورد نامعتبر در محاسبه نادیده گرفته می‌شود
و مقدار موجود مشتری پاک نمی‌شود.
"""
if action is None:
return None
now = _now(now)
for value in (as_aware(action.actionDateTime), as_aware(action.finishDateTime)):
if value is not None and value <= now:
return value
return None
def _visit_rows(customer_ids=None):
"""پیمایش سبک رکوردهای بازدید انجام‌شده (فقط فیلدهای لازم)."""
qs = Action.objects.filter(
actionType=_VISIT_TYPE, status=_VISIT_DONE,
).values_list("customer_id", "actionDateTime", "finishDateTime")
if customer_ids is not None:
qs = qs.filter(customer_id__in=list(customer_ids))
return qs.iterator()
def _last_visit_map_python(*, now=None, customer_ids=None):
"""نسخهٔ مرجع (پایتونی) برای تست همسانی و پشتیبانِ اضطراری.
این همان منطقی است که از ابتدا در ``last_visit_map`` بود؛ نسخهٔ SQL باید
دقیقاً همان خروجی را بدهد. در تست‌ها به‌عنوان «منبع مقایسه» استفاده می‌شود.
"""
now = _now(now)
result = {}
invalid = 0
for customer_id, action_dt, finish_dt in _visit_rows(customer_ids):
eff = None
for value in (as_aware(action_dt), as_aware(finish_dt)):
if value is not None and value <= now:
eff = value
break
if eff is None:
invalid += 1
continue
current = result.get(customer_id)
if current is None or eff > current:
result[customer_id] = eff
if invalid:
logger.warning(
"بازدید انجام‌شدهٔ بدون تاریخ معتبر نادیده گرفته شد: %s رکورد", invalid,
)
return result
def last_visit_map(*, now=None, customer_ids=None):
"""نگاشت ``customer_id → تاریخ آخرین بازدید معتبر`` — با تجمیع در دیتابیس.
قاعدهٔ تاریخ مؤثر همان قاعدهٔ قبلی است (هیچ تغییری در معنا):
* فقط ``bazdid + finish``.
* ``actionDateTime`` اگر خالی نباشد و از «حالا» جلوتر نباشد؛
* وگرنه ``finishDateTime`` اگر خالی نباشد و از «حالا» جلوتر نباشد؛
* وگرنه رکورد «تاریخ معتبر» ندارد و باید نادیده گرفته شود.
محاسبه در SQL انجام می‌شود (``CASE`` داخل ``MAX``) نه با حلقهٔ پایتونی روی
۴۵ هزار رکورد؛ خروجی همان دیکشنری قبلی است.
"""
now = _now(now)
# مقایسهٔ «حالا» باید در SQL انجام شود. نکتهٔ مهم: اگر یک datetime naive به ORM
# داده شود، Django آن را با منطقهٔ زمانی محلی دوباره adapt می‌کند (۳:۳۰ ساعت
# جابه‌جایی برای تهران)، و اگر رشته هم بدهیم آن را به‌عنوان datetime تفسیر می‌کند.
# راه امن: مقدار را در پایتون به «naive UTC» تبدیل و سپس با ``Value`` تایپ‌دار
# به‌عنوان DateTimeField بفرستیم تا تبدیل دومی رخ ندهد.
now_utc = now.astimezone(dt_timezone.utc)
effective = Case(
# شاخهٔ اول: تاریخ بازدید (اگر وجود دارد و در آینده نیست).
When(actionDateTime__isnull=False, actionDateTime__lte=now_utc,
then=F("actionDateTime")),
# شاخهٔ دوم: تاریخ اتمام (اگر وجود دارد و در آینده نیست).
When(finishDateTime__isnull=False, finishDateTime__lte=now_utc,
then=F("finishDateTime")),
default=Value(None, output_field=DateTimeField()),
output_field=DateTimeField(),
)
qs = (
Action.objects.filter(actionType=_VISIT_TYPE, status=_VISIT_DONE)
.values("customer_id")
.annotate(latest=Max(effective))
)
if customer_ids is not None:
qs = qs.filter(customer_id__in=list(customer_ids))
result = {}
invalid = 0
for row in qs.iterator():
value = row["latest"]
if value is None:
invalid += 1
continue
result[row["customer_id"]] = as_aware(value)
if invalid:
logger.warning(
"بازدید انجام‌شدهٔ بدون تاریخ معتبر نادیده گرفته شد: %s مشتری", invalid,
)
return result
def last_visit_dt(customer, *, now=None):
"""تاریخ آخرین بازدید معتبر یک مشتری (یا ``None``)."""
if customer is None or customer.pk is None:
return None
return last_visit_map(now=now, customer_ids=[customer.pk]).get(customer.pk)
# ---------------------------------------------------------------------------
# لنگر چرخهٔ یادآوری (بازدید + نصب)
# ---------------------------------------------------------------------------
# تصمیم محصولی: «نصب» هم چرخهٔ ۹ ماههٔ یادآوری را از نو شروع می‌کند؛ ولی «تماس
# تلفنی» (inCall/outCall) و «هماهنگی» خیر. این لنگر **فقط** برای زمان‌بندی
# یادآوری است و هرگز جای ``last_bazdid_dt`` (که یعنی «آخرین بازدید») را نمی‌گیرد.
_CYCLE_RESET_TYPES = (Action.ActionType.BAZDID, Action.ActionType.NASB)
def cycle_anchor_map(*, now=None, customer_ids=None):
"""نگاشت ``customer_id → آخرین رخدادِ شروع‌دوبارهٔ چرخه`` (بازدید یا نصب).
قاعدهٔ تاریخ مؤثر همان قاعدهٔ بازدید است: ``actionDateTime`` اگر از حالا
جلوتر نباشد، وگرنه ``finishDateTime``؛ وگرنه رکورد بی‌اثر است.
"""
now = _now(now)
qs = Action.objects.filter(
actionType__in=_CYCLE_RESET_TYPES, status=_VISIT_DONE,
).values_list("customer_id", "actionDateTime", "finishDateTime")
if customer_ids is not None:
qs = qs.filter(customer_id__in=list(customer_ids))
result = {}
for customer_id, action_dt, finish_dt in qs.iterator():
eff = None
for value in (as_aware(action_dt), as_aware(finish_dt)):
if value is not None and value <= now:
eff = value
break
if eff is None:
continue
current = result.get(customer_id)
if current is None or eff > current:
result[customer_id] = eff
return result
def cycle_anchor_dt(customer, *, now=None):
"""لنگر چرخهٔ یادآوری یک مشتری (بازدید یا نصب؛ یا ``None``)."""
if customer is None or customer.pk is None:
return None
return cycle_anchor_map(now=now, customer_ids=[customer.pk]).get(customer.pk)
# ---------------------------------------------------------------------------
# نوشتن مقدار روی مشتری
# ---------------------------------------------------------------------------
def refresh_last_bazdid(customer, *, reason=REASON_EDIT, user=None, now=None):
"""``Customer.last_bazdid_dt`` را از منبع حقیقت بازمحاسبه می‌کند.
* فقط وقتی مقدار تغییر کند می‌نویسد (idempotent).
* اگر مشتری هیچ بازدید معتبری ندارد، مقدار فعلی **دست‌نخورده** می‌ماند
(پاک‌سازی فقط با دستور ``recalc_last_bazdid --clear-orphans`` مجاز است).
* هرگز ``Action`` را ذخیره/تغییر نمی‌دهد و ``Customer.save()`` کامل صدا نمی‌زند
(تا فیلدهای ``auto_now`` و بقیهٔ ستون‌ها دست‌نخورده بمانند).
"""
if customer is None or customer.pk is None:
return False
new_value = last_visit_dt(customer, now=now)
current = as_aware(customer.last_bazdid_dt)
if new_value is None:
logger.warning(
"مشتری %s بازدید معتبری ندارد؛ last_bazdid_dt دست‌نخورده ماند "
"(مقدار فعلی: %s)", customer.pk, current,
)
return False
if current == new_value:
return False
Customer.objects.filter(pk=customer.pk).update(last_bazdid_dt=new_value)
customer.last_bazdid_dt = new_value
logger.info(
"last_bazdid_dt مشتری %s از %s به %s تغییر کرد (دلیل=%s، کاربر=%s)",
customer.pk, current, new_value, reason,
getattr(user, "pk", None),
)
return True
# ---------------------------------------------------------------------------
# تعویق / قرار بعدی — هرگز در last_visit_dt دیده نمی‌شود
# ---------------------------------------------------------------------------
# تصمیم محصولی: هر کدام از این دو، «قرار بعدی با مشتری» است و مشتری تا آن تاریخ
# از فهرست «منتظر بازدید» خارج می‌شود:
#
# ۱. اقدام **هماهنگی** که با مشتری توافق شده است — چه هنوز در انتظار باشد و چه
# همان لحظه «انجام شد» بسته شود (``finish``). تاریخ قرار = ``actionDateTime``.
# اگر هماهنگیِ فینیش تاریخ نداشته باشد، از ``finishDateTime`` استفاده می‌شود.
# ۲. اقدام **بازدید** که برای آینده برنامه‌ریزی شده است (``waite`` با تاریخ آینده).
#
# «هماهنگی» بازدید نیست؛ پس هیچ‌کدام از این‌ها ``last_bazdid_dt`` را تغییر نمی‌دهد.
def next_appointment_map(*, now=None):
"""نگاشت ``customer_id → زودترین «قرار بعدی» آینده`` — با تجمیع در دیتابیس.
منابع قرار (فقط آینده):
* ``hamahangi`` با وضعیت ``waite`` **یا** ``finish``،
* ``bazdid`` با وضعیت ``waite`` (بازدید برنامه‌ریزی‌شده).
تاریخ قرار = ``actionDateTime`` و اگر خالی بود ``finishDateTime``.
فقط برای ``entezar`` و اسکنر پیامک استفاده می‌شود و هرگز در «آخرین بازدید»
دخالت نمی‌کند.
"""
now = _now(now)
# همان نکتهٔ ``last_visit_map``: ساعت مرجع باید aware بماند تا Django آن را
# دوباره با منطقهٔ محلی adapt نکند.
now_utc = now.astimezone(dt_timezone.utc)
# چرا دو کوئری جدا؟ با یک شرط ``OR`` روی نوع اقدام، بهینه‌ساز SQLite از ایندکس
# ``act_visit_idx`` استفاده نمی‌کند و کوئری ~۱۹۳ms طول می‌کشد. با تقسیم به
# کوئری‌های ساده، هر کدام دقیقاً با ایندکس (actionType + status) اجرا می‌شوند.
# (از ``union()`` استفاده نمی‌کنیم چون در این ترکیب، ردیف‌ها را از دست می‌دهد.)
hamahangi = Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI,
status__in=(Action.Status.WAITE, Action.Status.FINISH),
).filter(
models.Q(actionDateTime__gt=now_utc)
| models.Q(actionDateTime__isnull=True, finishDateTime__gt=now_utc)
).values_list("customer_id", "actionDateTime", "finishDateTime")
planned_visit = Action.objects.filter(
actionType=Action.ActionType.BAZDID,
status=Action.Status.WAITE,
actionDateTime__gt=now_utc,
).values_list("customer_id", "actionDateTime")
combined = {}
def _remember(customer_id, when):
when = as_aware(when)
if when is None:
return
current = combined.get(customer_id)
if current is None or when < current:
combined[customer_id] = when
for customer_id, action_dt, finish_dt in hamahangi.iterator():
# تاریخ قرار: تاریخ اقدام؛ و برای هماهنگیِ بدون تاریخ اقدام، تاریخ اتمام.
_remember(customer_id, action_dt or finish_dt)
for customer_id, action_dt in planned_visit.iterator():
_remember(customer_id, action_dt)
return combined
def next_appointment(customer, *, now=None):
"""زودترین قرار بعدی یک مشتری (یا ``None``)."""
if customer is None or customer.pk is None:
return None
return next_appointment_map(now=now).get(customer.pk)
# نام قدیمی برای سازگاری با فراخوانی‌های موجود.
def active_deferrals(*, now=None):
"""نام قدیمیِ :func:`next_appointment_map` (سازگاری عقب‌رو)."""
return next_appointment_map(now=now)
def active_deferral(customer, *, now=None):
"""نام قدیمیِ :func:`next_appointment` (سازگاری عقب‌رو)."""
return next_appointment(customer, now=now)
# ---------------------------------------------------------------------------
# دورهٔ بازدید (منبع واحد)
# ---------------------------------------------------------------------------
def visit_period_months():
"""تعداد ماهِ دورهٔ بازدید از قالب پیامک (قابل تنظیم از پنل)، وگرنه پیش‌فرض."""
value = (
SMSTemplate.objects.filter(sms_type="visit")
.values_list("repeat_interval_months", flat=True)
.first()
)
return int(value or DEFAULT_VISIT_MONTHS)
def visit_period(months=None):
"""دورهٔ بازدید به‌صورت بازهٔ تقویمی (ماه واقعی، نه ۴٫۳ هفته)."""
months = int(months or visit_period_months())
return relativedelta.relativedelta(months=max(months, 1))
def visit_period_days(months=None):
"""طول تقریبی دوره به روز (فقط برای گزارش/نمایش؛ مبنای چرخه نیست)."""
return max(round(int(months or visit_period_months()) * 365 / 12), 1)
def visit_due_at(*, now=None, months=None):
"""مقطعی که مشتریِ بدون بازدید از آن پیش‌تر «عقب‌افتاده» حساب می‌شود."""
return _now(now) - visit_period(months)
def visit_due_date(anchor, *, months=None):
"""تاریخ موعد (تقویمی) برای یک لنگر: ``anchor + N ماه``.
تنها مرجع محاسبهٔ موعد، هم در صفحهٔ «انتظار» و هم در اسکنر پیامک.
"""
anchor = as_aware(anchor)
if anchor is None:
return None
return anchor + visit_period(months)
def visit_cycle_index(anchor, at, *, months=None):
"""شمارهٔ چرخهٔ یادآوریِ ``at`` نسبت به لنگر، بر مبنای **ماه تقویمی**.
چرخهٔ ۱ یعنی «یک دورهٔ کامل گذشته و یادآوری اول واجد شرط شده است».
چرا ماه تقویمی و نه تقسیم روز؟ در نسخهٔ قبلی شمارهٔ چرخه با
``days // 274`` حساب می‌شد و با موعدِ تقویمی (``relativedelta(months=9)``)
بر یک شبکه نمی‌نشست؛ نتیجه‌اش *گم‌شدنِ* بعضی دوره‌ها (کلید تکراری) و
*پریدن* بعضی دیگر بود. حالا هر دو از یک قاعده می‌آیند:
«تعداد ماهِ کاملِ سپری‌شده از لنگر ÷ طول دوره».
"""
anchor = as_aware(anchor)
at = as_aware(at)
if anchor is None or at is None or at < anchor:
return 0
period_months = max(int(months or visit_period_months()), 1)
months_elapsed = (at.year - anchor.year) * 12 + (at.month - anchor.month)
period = relativedelta.relativedelta(months=period_months)
def due_date(steps):
"""تاریخِ موعدِ ``steps`` دوره بعد، با جمعِ **پله‌ای**.
چرا ضرب نکنیم؟ ``relativedelta * n`` انتهای ماه را تجمعی می‌بندد
(۳۱ ژانویه → ۳۰ آوریل → ۳۰ جولای) در حالی که موعد واقعی ۳۱ جولای است.
جمعِ پله‌ای همان چیزی است که در عمل هم رخ می‌دهد: «یک دوره، بعد یک دورهٔ دیگر».
مقایسه در سطح **تاریخ** انجام می‌شود (نه لحظه)، تا ساعتِ اجرای اسکن
(مثلاً ۱۲:۰۰ همان روز موعد) تأثیری در شمارش نداشته باشد.
"""
value = anchor.date()
for _ in range(steps):
value = (as_aware(value) + period).date()
return value
today = at.date()
# بزرگ‌ترین kای که موعدش رسیده است.
k = max(months_elapsed // period_months, 0)
while k > 0 and due_date(k) > today:
k -= 1
while due_date(k + 1) <= today:
k += 1
return k
def visit_latest_due(anchor, *, now, months=None):
"""تازه‌ترین موعدی که تا ``now`` رسیده است (یا ``None``).
مشتری‌ای که ۲۵ ماه بازدید نشده باید موعد ۱۸ ماهگی را بگیرد، نه موعد ۹ ماهگیِ
قدیمی. قاعدهٔ قبلی هر بار ``anchor + یک دوره`` را می‌سنجید؛ چون آن موعد همیشه
«رسیده» بود و کلیدش هم ثابت، دوره‌های بعدی هرگز بررسی نمی‌شدند.
"""
anchor = as_aware(anchor)
now = as_aware(now)
if anchor is None or now is None:
return None
current = visit_cycle_index(anchor, now, months=months)
if current < 1:
return None
period = visit_period(months)
# جمع پله‌ای روی **تاریخ** (نه روی دیتاتایم aware): با تغییر ساعت، جمع ساعت‌دار
# ممکن است به ۲۳:۰۰ روز قبل بغلتد و موعد یک روز زودتر حساب شود.
due = anchor.date()
for _ in range(current):
due = (as_aware(due) + period).date()
return as_aware(due)
# ---------------------------------------------------------------------------
# اعتبارسنجی لایهٔ HTTP
# ---------------------------------------------------------------------------
def validate_action_payload(payload, *, now=None):
"""اعتبارسنجی ورودی فرم اقدام پیش از هر نوشتنی.
``payload`` نگاشتی با کلیدهای ``actionType``، ``status``، ``actionDateTime``
و ``finishDateTime`` است (مقادیر می‌توانند ``None`` یا ``datetime aware`` باشند).
قواعد:
* نوع اقدام باید یکی از شش مقدار مدل باشد.
* وضعیت باید یکی از سه مقدار مدل باشد.
* برای «بازدید انجام‌شده»: تاریخ بازدید نباید بیش از ۳۰ روز در آینده باشد.
* برای هماهنگی و بقیهٔ انواع: هیچ محدودیت تاریخ آینده‌ای اعمال نمی‌شود.
"""
now = _now(now)
action_type = payload.get("actionType")
status = payload.get("status")
if action_type not in Action.ActionType.values:
raise ValidationError("نوع اقدام نامعتبر است.")
if status not in Action.Status.values:
raise ValidationError("وضعیت اقدام نامعتبر است.")
if action_type == _VISIT_TYPE and status == _VISIT_DONE:
action_dt = as_aware(payload.get("actionDateTime"))
if action_dt is not None and action_dt > now + relativedelta.relativedelta(
days=FINISH_MAX_FUTURE_DAYS
):
raise ValidationError(
"تاریخ بازدید نمی‌تواند بیش از ۳۰ روز در آینده باشد."
)
return True
def normalize_finish_datetime(value, *, now=None):
"""تاریخ اتمام: اگر خالی بود، زمان ثبت قرار می‌گیرد (نه ``None``)."""
value = as_aware(value)
return value if value is not None else _now(now)