515 lines
25 KiB
Python
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)
|