improve bazdid 4

This commit is contained in:
javad committed 2026-10-21 15:13:35 +03:30
1 parent f86d060e4d
commit 8fd881e0ea
10 files changed
+1752 -320

No files matched your search

+194 -19
View File
@@ -402,33 +402,24 @@ def coordination_holds_map(*, now=None):
)
def manual_holds_map(*, now=None):
"""قرارهای دستی (ستون ``visit_hold_until``) — **با اعتبارسنجی**.
def _valid_manual_holds(holds):
"""اعتبارسنجی قرارهای دستی — قاعدهٔ قطعی «ستون، مرجع نیست».
قاعدهٔ قطعی: ستون به‌تنهایی معتبر نیست. یک قرار دستی فقط وقتی اثر دارد که:
ورودی ``{customer_id: visit_hold_until}`` و خروجی همان نگاشت، فقط برای
قرارهایی که معتبرند:
۱. تاریخش از حالا جلوتر باشد،
۲. یک اقدام «هماهنگی» فعال (``waite``/``finish``) برای مشتری وجود داشته باشد،
۳. و **آخرین فعالیتِ مشتری همان هماهنگی باشد** — اگر پس از هماهنگی، اقدام
۱. یک اقدام «هماهنگی» فعال (``waite``/``finish``) برای مشتری وجود دارد،
۲. و **آخرین فعالیتِ مشتری همان هماهنگی است** — اگر پس از هماهنگی، اقدام
دیگری (به‌ویژه «بازدید») ثبت شده باشد، قرار باطل است.
بند ۳ همان چیزی است که «ثبت بازدید پس از هماهنگی» را خودکار بی‌اثر می‌کند؛
بدون آن، قرارِ قدیمی تا تاریخش باقی می‌ماند و مشتری در فهرست «با قرار»
می‌ماند، هرچند بازدید (یا برنامهٔ بازدید) جای هماهنگی را گرفته است.
بند ۲ همان چیزی است که «ثبت بازدید پس از هماهنگی» را خودکار بی‌اثر می‌کند.
نتیجه: حذف/لغو هماهنگی، یا هر اقدام جدیدتر از آن ⇒ قرار بی‌اثر می‌شود؛
و کهنه‌ماندن ستون هیچ اثر مخربی ندارد.
این تابع جدا شده تا هم :func:`manual_holds_map` (قرارهای آینده) و هم
:func:`followups_on_date` (قرارهای یک روز مشخص، حتی گذشته) از **یک قاعدهٔ
واحد** استفاده کنند و دو تعریف واگرا شکل نگیرد.
"""
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 {}
customer_ids = list(holds)
# مرجع اعتبار: وجود یک اقدام «هماهنگیِ فعال» برای همان مشتری (نه لزوماً با
@@ -469,6 +460,190 @@ def manual_holds_map(*, now=None):
return valid
def manual_holds_map(*, now=None):
"""قرارهای دستی **آینده** (ستون ``visit_hold_until``) — با اعتبارسنجی.
برای فهرست انتظار و آمار؛ قاعدهٔ اعتبار در :func:`_valid_manual_holds`.
"""
now = _now(now)
rows = (
Customer.objects.filter(visit_hold_until__gt=now)
.values_list("id", "visit_hold_until")
.iterator()
)
return _valid_manual_holds(
{customer_id: as_aware(until) for customer_id, until in rows}
)
# ---------------------------------------------------------------------------
# «نیازمند پیگیری» در یک روز مشخص
# ---------------------------------------------------------------------------
def _local_day_bounds(target_date):
"""بازهٔ ``[آغاز روز، پایان روز]`` به وقت محلی (aware)."""
current_tz = timezone.get_current_timezone()
start = datetime.combine(target_date, time.min)
end = datetime.combine(target_date, time.max)
try:
return (
timezone.make_aware(start, current_tz),
timezone.make_aware(end, current_tz),
)
except Exception:
return (
timezone.make_aware(start, current_tz, is_dst=False),
timezone.make_aware(end, current_tz, is_dst=False),
)
def _pending_visit_customer_ids(*, since):
"""مشتریانی که **قرار بازدید** دارند و نباید در فهرست پیگیری بیایند.
قاعدهٔ محصولی: «اگر برای مشتری بازدید گذاشته شده، دیگر لازم نیست تماس
بگیریم». پس هر مشتری با اقدام ``bazdid + waite`` که تاریخش از ``since``
(آغاز روز مورد نظر) عقب‌تر نباشد، از فهرست پیگیری **حذف** می‌شود.
چرا ``>= since`` و نه «هر بازدیدی»؟ بازدیدِ در انتظارِ بسیار قدیمی
(backlog بسته‌نشده) قرار فعال نیست؛ آن مشتری هنوز نیاز به تماس دارد.
"""
return set(
Action.objects.filter(
actionType=Action.ActionType.BAZDID,
status=Action.Status.WAITE,
)
.filter(
models.Q(actionDateTime__gte=since)
| models.Q(actionDateTime__isnull=True, finishDateTime__gte=since)
)
.values_list("customer_id", flat=True)
)
def followups_on_date(target_date, *, now=None):
"""مشتریانِ «نیازمند پیگیری» در یک تاریخ مشخص (تقویم شمسی/میلادی).
«نیازمند پیگیری» یعنی موعدی که با مشتری توافق شده **همان روز** است:
* **قرار دستی** — ``Customer.visit_hold_until`` که تاریخ آن همان روز است
(با همان اعتبارسنجی :func:`_valid_manual_holds`).
* **هماهنگی** — اقدام ``hamahangi`` با ``actionDateTime`` (وگرنه
``finishDateTime``) در همان روز، با وضعیت ``waite`` یا ``finish``.
تفاوت با :func:`appointment_sources_map`: آن تابع فقط **آینده** را می‌بیند
(برای تصمیم «خارج از فهرست انتظار»)، ولی این یکی **هر روزی** — گذشته،
امروز یا آینده — را می‌بیند تا کارشناس بتواند با تقویم عقب/جلو برود.
خروجی: ``{customer_id: {منبع: تاریخ}}`` مرتب بر اساس زودترین ساعت موعد.
مشتریانی که قرار **بازدید** دارند در خروجی نیستند.
"""
day_start, day_end = _local_day_bounds(target_date)
result = {}
# ۱) قرارهای دستی همان روز (مستقل از گذشته/آینده بودن).
rows = (
Customer.objects.filter(
visit_hold_until__gte=day_start, visit_hold_until__lte=day_end,
)
.values_list("id", "visit_hold_until")
.iterator()
)
for customer_id, until in _valid_manual_holds(
{cid: as_aware(when) for cid, when in rows}
).items():
result.setdefault(customer_id, {})[SOURCE_MANUAL] = until
# ۲) اقدام‌های «هماهنگی» همان روز.
coordination_rows = (
Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI,
status__in=(Action.Status.WAITE, Action.Status.FINISH),
)
.filter(
models.Q(actionDateTime__gte=day_start, actionDateTime__lte=day_end)
| models.Q(
actionDateTime__isnull=True,
finishDateTime__gte=day_start,
finishDateTime__lte=day_end,
)
)
.values_list("customer_id", "actionDateTime", "finishDateTime")
.iterator()
)
for customer_id, action_dt, finish_dt in coordination_rows:
when = as_aware(action_dt) or as_aware(finish_dt)
if when is None:
continue
bucket = result.setdefault(customer_id, {})
current = bucket.get(SOURCE_COORDINATION)
if current is None or when < current:
bucket[SOURCE_COORDINATION] = when
# ۳) حذف مشتریانی که برایشان «بازدید» گذاشته شده است.
if result:
for customer_id in _pending_visit_customer_ids(since=day_start):
result.pop(customer_id, None)
return result
def overdue_followups(*, now=None, target_date=None):
"""پیگیری‌های **عقب‌افتاده**: موعدشان گذشته و هنوز تعیین تکلیف نشده‌اند.
«عقب‌افتاده» یعنی برای هر مشتری، **آخرین** اقدام هماهنگی (``waite``/``finish``)
تاریخی پیش از امروز دارد؛ یعنی موعد توافق‌شده گذشته و هیچ موعد تازه‌ای
جایگزینش نشده است.
قواعد:
* اگر مشتری هماهنگیِ **امروز یا آینده** داشته باشد، عقب‌افتاده نیست
(موعدش تازه است).
* اگر برایش **قرار بازدید** ثبت شده باشد، از فهرست خارج می‌شود — همان قاعدهٔ
:func:`followups_on_date` («بازدید گذاشته شده ⇒ تماس لازم نیست»).
* هماهنگیِ لغوشده (``cancell``) اصلاً دیده نمی‌شود.
خروجی: ``{customer_id: تاریخِ موعدِ ازدست‌رفته}`` — تا هم شمارش کنیم و هم
بتوانیم کاربر را به همان روز ببریم.
"""
now = _now(now)
today = target_date or now.astimezone(timezone.get_current_timezone()).date()
rows = (
Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI,
status__in=(Action.Status.WAITE, Action.Status.FINISH),
)
.values_list("customer_id", "actionDateTime", "finishDateTime")
.iterator()
)
# آخرین موعد هر مشتری (تاریخ، نه لحظه).
latest = {}
for customer_id, action_dt, finish_dt in rows:
when = as_aware(action_dt) or as_aware(finish_dt)
if when is None:
continue
day = when.astimezone(timezone.get_current_timezone()).date()
current = latest.get(customer_id)
if current is None or day > current:
latest[customer_id] = day
overdue = {cid: day for cid, day in latest.items() if day < today}
if not overdue:
return {}
# حذف مشتریانی که برایشان بازدید گذاشته شده است (قرار بازدید از آن روز).
# «از آن روز» = زودترین موعدِ ازدست‌رفته؛ بازدیدی که بعد از آن گذاشته شده
# تکلیف مشتری را روشن کرده است.
pending_visits = _pending_visit_customer_ids(
since=_local_day_bounds(min(overdue.values()))[0]
)
for customer_id in pending_visits:
overdue.pop(customer_id, None)
return overdue
def appointment_sources_map(*, now=None):
"""نگاشت ``customer_id → {منبع: تاریخ}`` برای هر سه منبع (فقط آینده).