"""سیاست بازدید مشتری — تنها منبع حقیقت برای «آخرین بازدید واقعی». هدف این ماژول پایان‌دادن به سه تعریف واگرا از «آخرین بازدید» است: * ویوهای ``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): """اقدام‌های آیندهٔ یک نوع با تاریخ مؤثر (تاریخ اقدام، وگرنه تاریخ اتمام).""" return ( Action.objects.filter(actionType=action_type, status__in=statuses) .filter( models.Q(actionDateTime__gt=now_utc) | models.Q(actionDateTime__isnull=True, finishDateTime__gt=now_utc) ) .values_list("customer_id", "actionDateTime", "finishDateTime") ) def _action_appointments(action_type, statuses, *, now=None): """نگاشت ``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 ).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`` با تاریخ آینده.""" return _action_appointments( Action.ActionType.BAZDID, (Action.Status.WAITE,), now=now, ) 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 manual_holds_map(*, now=None): """قرارهای دستی (ستون ``visit_hold_until``) — **با اعتبارسنجی**. قاعدهٔ قطعی: ستون به‌تنهایی معتبر نیست. فقط وقتی اثر دارد که یک اقدام «هماهنگی» با وضعیت ``waite`` یا ``finish`` و **تاریخ آینده** برای همان مشتری وجود داشته باشد. بنابراین: * حذف هماهنگی ⇒ قرار خودکار بی‌اثر می‌شود، * لغو هماهنگی (``cancell``) ⇒ بی‌اثر می‌شود، * هماهنگیِ گذشته ⇒ بی‌اثر می‌شود، * کهنه‌ماندن ستون هیچ اثر مخربی ندارد. مرجع هماهنگی: هر ``hamahangi`` فعالی با تاریخ آینده (نه لزوماً برابر با ستون)، چون کاربر می‌تواند هماهنگی را ویرایش کند و تاریخ دیگری بگذارد. """ now = _now(now) rows = ( Customer.objects.filter(visit_hold_until__gt=now) .values_list("id", "visit_hold_until") .iterator() ) holds = {customer_id: as_aware(until) for customer_id, until in rows} if not holds: return {} # مرجع اعتبار: وجود یک اقدام «هماهنگیِ فعال» برای همان مشتری (نه لزوماً با # تاریخ آینده). این نکته حیاتی است: سناریوی رایج «همین حالا تماس گرفتم و قرار # دو هفتهٔ بعد گذاشتم» تاریخ اقدام را «امروز» می‌ذارد؛ اگر شرط آینده بودن تاریخ # اقدام را بگذاریم، همین مورد (که هدف اصلی این قابلیت است) بی‌اثر می‌شد. # پس فقط وضعیت مهم است: ``waite`` (در انتظار) یا ``finish`` (انجام شد). # ``cancell`` حساب نمی‌شود و حذف رکورد هم خودکار بی‌اثر می‌کند. active_coordination = set( Action.objects.filter( actionType=Action.ActionType.HAMAHANGI, status__in=(Action.Status.WAITE, Action.Status.FINISH), ).values_list("customer_id", flat=True) ) return { customer_id: until for customer_id, until in holds.items() if customer_id in active_coordination and until is not None } 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 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)