improve entezar bazdid
This commit is contained in:
1 parent
1fd27d0445
commit
2aee85593f
18 files changed
+10271
-7558
No files matched your search
@@ -0,0 +1,514 @@
|
||||
"""سیاست بازدید مشتری — تنها منبع حقیقت برای «آخرین بازدید واقعی».
|
||||
|
||||
هدف این ماژول پایاندادن به سه تعریف واگرا از «آخرین بازدید» است:
|
||||
|
||||
* ویوهای ``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)
|
||||
Reference in new issue
Block a user