939 lines
44 KiB
Python
939 lines
44 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 → زودترین «قرار بعدی» آینده``.
|
||
|
||
اجتماع سه منبع (همه با تاریخ آینده):
|
||
|
||
* ``manual`` — ستون ``Customer.visit_hold_until`` (با اعتبارسنجی)،
|
||
* ``scheduled`` — ``bazdid + waite`` (بازدید برنامهریزیشده)،
|
||
* ``coordination`` — ``hamahangi`` با وضعیت ``waite`` یا ``finish`` که
|
||
تاریخش آینده است (سازگاری با داده و رفتار قبلی).
|
||
|
||
این تابع تصمیم «مشتری در فهرست انتظار هست یا نه» و همچنین اسکنر پیامک را
|
||
تغذیه میکند؛ «آخرین بازدید» هیچگاه از اینجا خوانده نمیشود.
|
||
"""
|
||
now = _now(now)
|
||
return {
|
||
customer_id: min(sources.values())
|
||
for customer_id, sources in appointment_sources_map(now=now).items()
|
||
}
|
||
|
||
|
||
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)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# منابع «قرار بعدی» بهتفکیک — برای صفحهٔ انتظار و آمار
|
||
# ---------------------------------------------------------------------------
|
||
# سه منبع وجود دارد و هر سه با هم «قرار بعدی» را میسازند:
|
||
#
|
||
# ۱. ``manual`` — ستون ``Customer.visit_hold_until`` (تعویق دستی از فرم هماهنگی)
|
||
# ۲. ``scheduled`` — اقدام بازدید با وضعیت ``waite`` و تاریخ آینده
|
||
# ۳. ``coordination`` — اقدام هماهنگیِ در انتظار (کارشناس تیک «انجام شد» نزده)
|
||
#
|
||
# تفکیک اینها فقط برای نمایش/آمار است؛ تصمیم «مشتری در فهرست هست یا نه» را
|
||
# همان ``next_appointment_map`` میگیرد (اجتماع هر سه).
|
||
|
||
SOURCE_MANUAL = "manual"
|
||
SOURCE_SCHEDULED = "scheduled"
|
||
SOURCE_COORDINATION = "coordination"
|
||
|
||
|
||
def _future_actions(action_type, statuses, *, now_utc, whole_day=False):
|
||
"""اقدامهای آیندهٔ یک نوع با تاریخ مؤثر (تاریخ اقدام، وگرنه تاریخ اتمام).
|
||
|
||
``whole_day=True`` فقط برای «بازدید برنامهریزیشده» استفاده میشود: در آنجا
|
||
``actionDateTime`` یک **قرارِ آینده** است که کاربر با تقویم روزانه انتخاب
|
||
میکند، پس «قرار امروز» باید تا پایان امروز معتبر بماند. مقایسهٔ لحظهای
|
||
باعث میشد بازدیدِ امروز بلافاصله پس از گذشتن ساعتش ناپدید شود.
|
||
|
||
برای «هماهنگی» عمداً ``whole_day=False`` است: ``actionDateTime`` آنجا
|
||
**زمانِ تماس** است، نه قرار آینده؛ تماسِ گذشته نباید مشتری را تا آخر امروز
|
||
پنهان کند (وگرنه گروه «قرار دستی» در سناریوی رایج خالی میشد).
|
||
"""
|
||
threshold = start_of_local_day(now_utc) if whole_day else now_utc
|
||
return (
|
||
Action.objects.filter(actionType=action_type, status__in=statuses)
|
||
.filter(
|
||
models.Q(actionDateTime__gte=threshold)
|
||
| models.Q(actionDateTime__isnull=True, finishDateTime__gte=threshold)
|
||
)
|
||
.values_list("customer_id", "actionDateTime", "finishDateTime")
|
||
)
|
||
|
||
|
||
def _action_appointments(action_type, statuses, *, now=None, whole_day=False):
|
||
"""نگاشت ``customer_id → زودترین تاریخِ آینده`` برای یک نوع اقدام."""
|
||
now_utc = _now(now).astimezone(dt_timezone.utc)
|
||
result = {}
|
||
for customer_id, action_dt, finish_dt in _future_actions(
|
||
action_type, statuses, now_utc=now_utc, whole_day=whole_day
|
||
).iterator():
|
||
when = as_aware(action_dt) or as_aware(finish_dt)
|
||
if when is None:
|
||
continue
|
||
current = result.get(customer_id)
|
||
if current is None or when < current:
|
||
result[customer_id] = when
|
||
return result
|
||
|
||
|
||
def scheduled_visits_map(*, now=None):
|
||
"""قرارهای «بازدید برنامهریزیشده»: ``bazdid + waite`` با تاریخ آینده.
|
||
|
||
مقایسه در سطح **روز** است (``whole_day=True``): بازدیدی که برای «امروز»
|
||
گذاشته شده تا پایان امروز معتبر میماند.
|
||
"""
|
||
return _action_appointments(
|
||
Action.ActionType.BAZDID, (Action.Status.WAITE,), now=now, whole_day=True,
|
||
)
|
||
|
||
|
||
def coordination_holds_map(*, now=None):
|
||
"""قرارهای «هماهنگی» با تاریخ آینده.
|
||
|
||
هر دو وضعیت ``waite`` و ``finish`` در نظر گرفته میشوند (سازگاری با رفتار
|
||
قبلی). حالت ``waite`` یعنی کارشناس هنوز تیک «انجام شد» نزده، و ``finish``
|
||
یعنی تماس انجام شده و قرار برای آینده گذاشته شده است.
|
||
"""
|
||
return _action_appointments(
|
||
Action.ActionType.HAMAHANGI,
|
||
(Action.Status.WAITE, Action.Status.FINISH),
|
||
now=now,
|
||
)
|
||
|
||
|
||
def _valid_manual_holds(holds):
|
||
"""اعتبارسنجی قرارهای دستی — قاعدهٔ قطعی «ستون، مرجع نیست».
|
||
|
||
ورودی ``{customer_id: visit_hold_until}`` و خروجی همان نگاشت، فقط برای
|
||
قرارهایی که معتبرند:
|
||
|
||
۱. یک اقدام «هماهنگی» فعال (``waite``/``finish``) برای مشتری وجود دارد،
|
||
۲. و **آخرین فعالیتِ مشتری همان هماهنگی است** — اگر پس از هماهنگی، اقدام
|
||
دیگری (بهویژه «بازدید») ثبت شده باشد، قرار باطل است.
|
||
|
||
بند ۲ همان چیزی است که «ثبت بازدید پس از هماهنگی» را خودکار بیاثر میکند.
|
||
|
||
این تابع جدا شده تا هم :func:`manual_holds_map` (قرارهای آینده) و هم
|
||
:func:`followups_on_date` (قرارهای یک روز مشخص، حتی گذشته) از **یک قاعدهٔ
|
||
واحد** استفاده کنند و دو تعریف واگرا شکل نگیرد.
|
||
"""
|
||
if not holds:
|
||
return {}
|
||
customer_ids = list(holds)
|
||
|
||
# مرجع اعتبار: وجود یک اقدام «هماهنگیِ فعال» برای همان مشتری (نه لزوماً با
|
||
# تاریخ آینده). سناریوی رایج «همین حالا تماس گرفتم و قرار دو هفتهٔ بعد
|
||
# گذاشتم» تاریخ اقدام را «امروز» میذارد؛ اگر شرط آیندهبودن تاریخ اقدام را
|
||
# بگذاریم، همین مورد (هدف اصلی این قابلیت) بیاثر میشد. پس فقط وضعیت مهم است.
|
||
coordination_latest = dict(
|
||
Action.objects.filter(
|
||
customer_id__in=customer_ids,
|
||
actionType=Action.ActionType.HAMAHANGI,
|
||
status__in=(Action.Status.WAITE, Action.Status.FINISH),
|
||
)
|
||
.values("customer_id")
|
||
.annotate(last=Max("createdDT"))
|
||
.values_list("customer_id", "last")
|
||
)
|
||
# آخرین فعالیتِ غیرهماهنگی (بازدید، نصب، تماس، …) برای هر مشتری.
|
||
other_latest = dict(
|
||
Action.objects.filter(customer_id__in=customer_ids)
|
||
.exclude(actionType=Action.ActionType.HAMAHANGI)
|
||
.values("customer_id")
|
||
.annotate(last=Max("createdDT"))
|
||
.values_list("customer_id", "last")
|
||
)
|
||
|
||
valid = {}
|
||
for customer_id, until in holds.items():
|
||
if until is None:
|
||
continue
|
||
coordinated_at = coordination_latest.get(customer_id)
|
||
if coordinated_at is None:
|
||
continue
|
||
activity_at = other_latest.get(customer_id)
|
||
if activity_at is not None and as_aware(activity_at) > as_aware(coordinated_at):
|
||
# پس از هماهنگی، اقدام دیگری ثبت شده ⇒ قرار منتفی است.
|
||
continue
|
||
valid[customer_id] = until
|
||
return valid
|
||
|
||
|
||
def manual_holds_map(*, now=None):
|
||
"""قرارهای دستی **آینده** (ستون ``visit_hold_until``) — با اعتبارسنجی.
|
||
|
||
برای فهرست انتظار و آمار؛ قاعدهٔ اعتبار در :func:`_valid_manual_holds`.
|
||
"""
|
||
now = _now(now)
|
||
rows = (
|
||
Customer.objects.filter(visit_hold_until__gt=now)
|
||
.values_list("id", "visit_hold_until")
|
||
.iterator()
|
||
)
|
||
return _valid_manual_holds(
|
||
{customer_id: as_aware(until) for customer_id, until in rows}
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# «نیازمند پیگیری» در یک روز مشخص
|
||
# ---------------------------------------------------------------------------
|
||
def _local_day_bounds(target_date):
|
||
"""بازهٔ ``[آغاز روز، پایان روز]`` به وقت محلی (aware)."""
|
||
current_tz = timezone.get_current_timezone()
|
||
start = datetime.combine(target_date, time.min)
|
||
end = datetime.combine(target_date, time.max)
|
||
try:
|
||
return (
|
||
timezone.make_aware(start, current_tz),
|
||
timezone.make_aware(end, current_tz),
|
||
)
|
||
except Exception:
|
||
return (
|
||
timezone.make_aware(start, current_tz, is_dst=False),
|
||
timezone.make_aware(end, current_tz, is_dst=False),
|
||
)
|
||
|
||
|
||
def _pending_visit_customer_ids(*, since):
|
||
"""مشتریانی که **قرار بازدید** دارند و نباید در فهرست پیگیری بیایند.
|
||
|
||
قاعدهٔ محصولی: «اگر برای مشتری بازدید گذاشته شده، دیگر لازم نیست تماس
|
||
بگیریم». پس هر مشتری با اقدام ``bazdid + waite`` که تاریخش از ``since``
|
||
(آغاز روز مورد نظر) عقبتر نباشد، از فهرست پیگیری **حذف** میشود.
|
||
|
||
چرا ``>= since`` و نه «هر بازدیدی»؟ بازدیدِ در انتظارِ بسیار قدیمی
|
||
(backlog بستهنشده) قرار فعال نیست؛ آن مشتری هنوز نیاز به تماس دارد.
|
||
"""
|
||
return set(
|
||
Action.objects.filter(
|
||
actionType=Action.ActionType.BAZDID,
|
||
status=Action.Status.WAITE,
|
||
)
|
||
.filter(
|
||
models.Q(actionDateTime__gte=since)
|
||
| models.Q(actionDateTime__isnull=True, finishDateTime__gte=since)
|
||
)
|
||
.values_list("customer_id", flat=True)
|
||
)
|
||
|
||
|
||
def followups_on_date(target_date, *, now=None):
|
||
"""مشتریانِ «نیازمند پیگیری» در یک تاریخ مشخص (تقویم شمسی/میلادی).
|
||
|
||
«نیازمند پیگیری» یعنی موعدی که با مشتری توافق شده **همان روز** است:
|
||
|
||
* **قرار دستی** — ``Customer.visit_hold_until`` که تاریخ آن همان روز است
|
||
(با همان اعتبارسنجی :func:`_valid_manual_holds`).
|
||
* **هماهنگی** — اقدام ``hamahangi`` با ``actionDateTime`` (وگرنه
|
||
``finishDateTime``) در همان روز، با وضعیت ``waite`` یا ``finish``.
|
||
|
||
تفاوت با :func:`appointment_sources_map`: آن تابع فقط **آینده** را میبیند
|
||
(برای تصمیم «خارج از فهرست انتظار»)، ولی این یکی **هر روزی** — گذشته،
|
||
امروز یا آینده — را میبیند تا کارشناس بتواند با تقویم عقب/جلو برود.
|
||
|
||
خروجی: ``{customer_id: {منبع: تاریخ}}`` مرتب بر اساس زودترین ساعت موعد.
|
||
مشتریانی که قرار **بازدید** دارند در خروجی نیستند.
|
||
"""
|
||
day_start, day_end = _local_day_bounds(target_date)
|
||
|
||
result = {}
|
||
|
||
# ۱) قرارهای دستی همان روز (مستقل از گذشته/آینده بودن).
|
||
rows = (
|
||
Customer.objects.filter(
|
||
visit_hold_until__gte=day_start, visit_hold_until__lte=day_end,
|
||
)
|
||
.values_list("id", "visit_hold_until")
|
||
.iterator()
|
||
)
|
||
for customer_id, until in _valid_manual_holds(
|
||
{cid: as_aware(when) for cid, when in rows}
|
||
).items():
|
||
result.setdefault(customer_id, {})[SOURCE_MANUAL] = until
|
||
|
||
# ۲) اقدامهای «هماهنگی» همان روز.
|
||
coordination_rows = (
|
||
Action.objects.filter(
|
||
actionType=Action.ActionType.HAMAHANGI,
|
||
status__in=(Action.Status.WAITE, Action.Status.FINISH),
|
||
)
|
||
.filter(
|
||
models.Q(actionDateTime__gte=day_start, actionDateTime__lte=day_end)
|
||
| models.Q(
|
||
actionDateTime__isnull=True,
|
||
finishDateTime__gte=day_start,
|
||
finishDateTime__lte=day_end,
|
||
)
|
||
)
|
||
.values_list("customer_id", "actionDateTime", "finishDateTime")
|
||
.iterator()
|
||
)
|
||
for customer_id, action_dt, finish_dt in coordination_rows:
|
||
when = as_aware(action_dt) or as_aware(finish_dt)
|
||
if when is None:
|
||
continue
|
||
bucket = result.setdefault(customer_id, {})
|
||
current = bucket.get(SOURCE_COORDINATION)
|
||
if current is None or when < current:
|
||
bucket[SOURCE_COORDINATION] = when
|
||
|
||
# ۳) حذف مشتریانی که برایشان «بازدید» گذاشته شده است.
|
||
if result:
|
||
for customer_id in _pending_visit_customer_ids(since=day_start):
|
||
result.pop(customer_id, None)
|
||
|
||
return result
|
||
|
||
|
||
def overdue_followups(*, now=None, target_date=None):
|
||
"""پیگیریهای **عقبافتاده**: موعدشان گذشته و هنوز تعیین تکلیف نشدهاند.
|
||
|
||
«عقبافتاده» یعنی برای هر مشتری، **آخرین** اقدام هماهنگی (``waite``/``finish``)
|
||
تاریخی پیش از امروز دارد؛ یعنی موعد توافقشده گذشته و هیچ موعد تازهای
|
||
جایگزینش نشده است.
|
||
|
||
قواعد:
|
||
|
||
* اگر مشتری هماهنگیِ **امروز یا آینده** داشته باشد، عقبافتاده نیست
|
||
(موعدش تازه است).
|
||
* اگر برایش **قرار بازدید** ثبت شده باشد، از فهرست خارج میشود — همان قاعدهٔ
|
||
:func:`followups_on_date` («بازدید گذاشته شده ⇒ تماس لازم نیست»).
|
||
* هماهنگیِ لغوشده (``cancell``) اصلاً دیده نمیشود.
|
||
|
||
خروجی: ``{customer_id: تاریخِ موعدِ ازدسترفته}`` — تا هم شمارش کنیم و هم
|
||
بتوانیم کاربر را به همان روز ببریم.
|
||
"""
|
||
now = _now(now)
|
||
today = target_date or now.astimezone(timezone.get_current_timezone()).date()
|
||
|
||
rows = (
|
||
Action.objects.filter(
|
||
actionType=Action.ActionType.HAMAHANGI,
|
||
status__in=(Action.Status.WAITE, Action.Status.FINISH),
|
||
)
|
||
.values_list("customer_id", "actionDateTime", "finishDateTime")
|
||
.iterator()
|
||
)
|
||
|
||
# آخرین موعد هر مشتری (تاریخ، نه لحظه).
|
||
latest = {}
|
||
for customer_id, action_dt, finish_dt in rows:
|
||
when = as_aware(action_dt) or as_aware(finish_dt)
|
||
if when is None:
|
||
continue
|
||
day = when.astimezone(timezone.get_current_timezone()).date()
|
||
current = latest.get(customer_id)
|
||
if current is None or day > current:
|
||
latest[customer_id] = day
|
||
|
||
overdue = {cid: day for cid, day in latest.items() if day < today}
|
||
if not overdue:
|
||
return {}
|
||
|
||
# حذف مشتریانی که برایشان بازدید گذاشته شده است (قرار بازدید از آن روز).
|
||
# «از آن روز» = زودترین موعدِ ازدسترفته؛ بازدیدی که بعد از آن گذاشته شده
|
||
# تکلیف مشتری را روشن کرده است.
|
||
pending_visits = _pending_visit_customer_ids(
|
||
since=_local_day_bounds(min(overdue.values()))[0]
|
||
)
|
||
for customer_id in pending_visits:
|
||
overdue.pop(customer_id, None)
|
||
|
||
return overdue
|
||
|
||
|
||
def appointment_sources_map(*, now=None):
|
||
"""نگاشت ``customer_id → {منبع: تاریخ}`` برای هر سه منبع (فقط آینده).
|
||
|
||
برای صفحهٔ انتظار و آمار استفاده میشود؛ تصمیم «خارج از فهرست» را
|
||
:func:`next_appointment_map` میگیرد که اجتماع همینهاست.
|
||
"""
|
||
now = _now(now)
|
||
merged = {}
|
||
for source, values in (
|
||
(SOURCE_MANUAL, manual_holds_map(now=now)),
|
||
(SOURCE_SCHEDULED, scheduled_visits_map(now=now)),
|
||
(SOURCE_COORDINATION, coordination_holds_map(now=now)),
|
||
):
|
||
for customer_id, when in values.items():
|
||
merged.setdefault(customer_id, {})[source] = when
|
||
return merged
|
||
|
||
|
||
# نام قدیمی برای سازگاری با فراخوانیهای موجود.
|
||
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)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# نوشتن «قرار دستی» روی مشتری
|
||
# ---------------------------------------------------------------------------
|
||
HOLD_DEFAULT_DAYS = 14
|
||
HOLD_MAX_DAYS = 183 # ≈ ۶ ماه
|
||
|
||
|
||
def start_of_local_day(moment):
|
||
"""آغاز همان روز به وقت محلی (بهصورت aware).
|
||
|
||
قرارها با تقویم روزانه انتخاب میشوند؛ پس «قرار امروز» باید تا پایان امروز
|
||
معتبر بماند. مقایسهٔ لحظهای باعث میشد قرارِ امروز بلافاصله پس از گذشتن
|
||
ساعتِ آن، ناپدید شود.
|
||
"""
|
||
moment = as_aware(moment)
|
||
if moment is None:
|
||
return None
|
||
local = moment.astimezone(timezone.get_current_timezone())
|
||
start_of_day = datetime.combine(local.date(), time.min)
|
||
try:
|
||
return timezone.make_aware(start_of_day, timezone.get_current_timezone())
|
||
except Exception:
|
||
return timezone.make_aware(
|
||
start_of_day, timezone.get_current_timezone(), is_dst=False,
|
||
)
|
||
|
||
|
||
def end_of_local_day(moment):
|
||
"""پایان همان روز به وقت محلی (بهصورت aware).
|
||
|
||
تصمیم محصولی: «قرار تا ۱ آذر» یعنی تا **آخر** ۱ آذر، نه تا ساعت ثبت.
|
||
اگر صرفاً ``actionDateTime`` را ذخیره کنیم، قرارِ «امروز» بلافاصله منقضی
|
||
میشد و مشتری همان لحظه به فهرست برمیگشت.
|
||
"""
|
||
moment = as_aware(moment)
|
||
if moment is None:
|
||
return None
|
||
local = moment.astimezone(timezone.get_current_timezone())
|
||
end_of_day = datetime.combine(local.date(), time(23, 59, 59))
|
||
try:
|
||
return timezone.make_aware(end_of_day, timezone.get_current_timezone())
|
||
except Exception:
|
||
return timezone.make_aware(
|
||
end_of_day, timezone.get_current_timezone(), is_dst=False,
|
||
)
|
||
|
||
|
||
def default_hold_until(*, now=None, days=HOLD_DEFAULT_DAYS):
|
||
"""پیشفرض «قرار بعدی»: پایان روزِ ``days`` روز بعد."""
|
||
base = _now(now) + relativedelta.relativedelta(days=days)
|
||
return end_of_local_day(base)
|
||
|
||
|
||
def set_visit_hold(customer, until, *, now=None):
|
||
"""ثبت قرار دستی؛ فقط اگر **جلوتر** از مقدار فعلی باشد مینویسد.
|
||
|
||
قاعدهٔ «فقط رو به جلو» (مثل ``refresh_last_bazdid``) جلوی این را میگیرد که
|
||
ثبت هماهنگی توسط کارشناس دوم، قرارِ دورترِ قبلی را عقب بکشد.
|
||
خروجی: ``True`` اگر نوشته شد.
|
||
"""
|
||
if customer is None or customer.pk is None:
|
||
return False
|
||
target = end_of_local_day(until)
|
||
if target is None:
|
||
return False
|
||
current = as_aware(customer.visit_hold_until)
|
||
if current is not None and current >= target:
|
||
return False
|
||
Customer.objects.filter(pk=customer.pk).update(
|
||
visit_hold_until=target, visit_hold_set_at=_now(now),
|
||
)
|
||
customer.visit_hold_until = target
|
||
return True
|
||
|
||
|
||
def clear_visit_hold(customer):
|
||
"""پاککردن قرار دستی (مثلاً وقتی بازدید واقعی انجام شد)."""
|
||
if customer is None or customer.pk is None:
|
||
return False
|
||
if customer.visit_hold_until is None and customer.visit_hold_set_at is None:
|
||
return False
|
||
Customer.objects.filter(pk=customer.pk).update(
|
||
visit_hold_until=None, visit_hold_set_at=None,
|
||
)
|
||
customer.visit_hold_until = None
|
||
customer.visit_hold_set_at = None
|
||
return True
|
||
|
||
|
||
def normalize_hold_until(raw, *, now=None):
|
||
"""اعتبارسنجی و نرمالسازی «قرار بعدی» از ورودی فرم.
|
||
|
||
قواعد:
|
||
* مقدار خالی ⇒ ``None`` (بدون تعویق).
|
||
* تاریخ باید در آینده یا امروز باشد (پایان روز پذیرفته است).
|
||
* حداکثر ``HOLD_MAX_DAYS`` روز جلوتر (≈ ۶ ماه) — جلوگیری از «تعویق بیپایان».
|
||
|
||
خطای ``ValidationError`` با پیام فارسی برمیگرداند تا در ویو نشان داده شود.
|
||
"""
|
||
moment = as_aware(raw)
|
||
if moment is None:
|
||
return None
|
||
target = end_of_local_day(moment)
|
||
today_end = end_of_local_day(_now(now))
|
||
if target < today_end:
|
||
raise ValidationError("تاریخ قرار نمیتواند پیش از امروز باشد.")
|
||
limit = today_end + relativedelta.relativedelta(days=HOLD_MAX_DAYS)
|
||
if target > limit:
|
||
raise ValidationError(
|
||
f"قرار بعدی حداکثر میتواند {HOLD_MAX_DAYS} روز (۶ ماه) جلوتر باشد."
|
||
)
|
||
return target
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# دورهٔ بازدید (منبع واحد)
|
||
# ---------------------------------------------------------------------------
|
||
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 ماه``.
|
||
|
||
تنها مرجع محاسبهٔ موعد، هم در صفحهٔ «انتظار» و هم در اسکنر پیامک.
|
||
|
||
توجه: ``months`` را در حلقهها **حتماً** پاس بدهید؛ وگرنه برای هر لنگر یک
|
||
کوئری خواندنِ قالب پیامک زده میشود (باگ N+1).
|
||
"""
|
||
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)
|