improve bazdid
This commit is contained in:
1 parent
2aee85593f
commit
534a84f977
12 files changed
+2305
-339
No files matched your search
+238
-47
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user