improve bazdid

This commit is contained in:
javad committed 2026-10-21 10:50:42 +03:30
1 parent 2aee85593f
commit 534a84f977
12 files changed
+2305 -339

No files matched your search

+238 -47
View File
@@ -295,57 +295,23 @@ def refresh_last_bazdid(customer, *, reason=REASON_EDIT, user=None, now=None):
def next_appointment_map(*, now=None):
"""نگاشت ``customer_id → زودترین «قرار بعدی» آینده`` — با تجمیع در دیتابیس.
"""نگاشت ``customer_id → زودترین «قرار بعدی» آینده``.
منابع قرار (فقط آینده):
* ``hamahangi`` با وضعیت ``waite`` **یا** ``finish``،
* ``bazdid`` با وضعیت ``waite`` (بازدید برنامه‌ریزی‌شده).
اجتماع سه منبع (همه با تاریخ آینده):
تاریخ قرار = ``actionDateTime`` و اگر خالی بود ``finishDateTime``.
فقط برای ``entezar`` و اسکنر پیامک استفاده می‌شود و هرگز در «آخرین بازدید»
دخالت نمی‌کند.
* ``manual`` — ستون ``Customer.visit_hold_until`` (با اعتبارسنجی)،
* ``scheduled`` — ``bazdid + waite`` (بازدید برنامه‌ریزی‌شده)،
* ``coordination`` — ``hamahangi`` با وضعیت ``waite`` یا ``finish`` که
تاریخش آینده است (سازگاری با داده و رفتار قبلی).
این تابع تصمیم «مشتری در فهرست انتظار هست یا نه» و همچنین اسکنر پیامک را
تغذیه می‌کند؛ «آخرین بازدید» هیچ‌گاه از اینجا خوانده نمی‌شود.
"""
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
return {
customer_id: min(sources.values())
for customer_id, sources in appointment_sources_map(now=now).items()
}
def next_appointment(customer, *, now=None):
@@ -355,6 +321,134 @@ def next_appointment(customer, *, now=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` (سازگاری عقب‌رو)."""
@@ -366,6 +460,100 @@ def active_deferral(customer, *, now=None):
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
# ---------------------------------------------------------------------------
# دورهٔ بازدید (منبع واحد)
# ---------------------------------------------------------------------------
@@ -399,6 +587,9 @@ def visit_due_date(anchor, *, months=None):
"""تاریخ موعد (تقویمی) برای یک لنگر: ``anchor + N ماه``.
تنها مرجع محاسبهٔ موعد، هم در صفحهٔ «انتظار» و هم در اسکنر پیامک.
توجه: ``months`` را در حلقه‌ها **حتماً** پاس بدهید؛ وگرنه برای هر لنگر یک
کوئری خواندنِ قالب پیامک زده می‌شود (باگ N+1).
"""
anchor = as_aware(anchor)
if anchor is None: