some improve

This commit is contained in:
javad committed 2026-10-27 11:49:09 +03:30
1 parent fa73a734a0
commit 2e5d0bcdd3
17 files changed
+1512 -226

No files matched your search

+115 -58
View File
@@ -437,38 +437,56 @@ def _valid_manual_holds(holds):
# تاریخ آینده). سناریوی رایج «همین حالا تماس گرفتم و قرار دو هفتهٔ بعد
# گذاشتم» تاریخ اقدام را «امروز» می‌ذارد؛ اگر شرط آینده‌بودن تاریخ اقدام را
# بگذاریم، همین مورد (هدف اصلی این قابلیت) بی‌اثر می‌شد. پس فقط وضعیت مهم است.
coordination_latest = dict(
Action.objects.filter(
customer_id__in=customer_ids,
actionType=Action.ActionType.HAMAHANGI,
status__in=(Action.Status.WAITE, Action.Status.FINISH),
# «چه کسی جدیدتر است» بر اساس (createdDT، id) سنجیده می‌شود، نه فقط createdDT.
# چرا: createdDT در SQLite/ویندوز دقت کافی ندارد و دو اقدام که در یک لحظه ثبت
# شوند مقدار یکسان می‌گیرند؛ با مقایسهٔ صرفاً `>` آن‌گاه «بازدید بعد از
# هماهنگی» تشخیص داده نمی‌شد و قرارِ مرده معتبر می‌ماند. id یکتای افزایشی
# همان ترتیب ثبت را می‌دهد، پس به‌عنوان مرزِ تساوی استفاده می‌شود.
coordination_latest = {
cid: (last, last_id)
for cid, last, last_id in (
Action.objects.filter(
customer_id__in=customer_ids,
actionType=Action.ActionType.HAMAHANGI,
status__in=(Action.Status.WAITE, Action.Status.FINISH),
)
.values("customer_id")
.annotate(last=Max("createdDT"), last_id=Max("id"))
.values_list("customer_id", "last", "last_id")
)
.values("customer_id")
.annotate(last=Max("createdDT"))
.values_list("customer_id", "last")
)
}
# آخرین فعالیتِ **جایگزین‌کنندهٔ قرار** (بازدید/نصب) برای هر مشتری.
# عمداً فقط این دو نوع: یک سند وصول یا تماس ورودی نباید قرار را باطل کند.
other_latest = dict(
Action.objects.filter(
customer_id__in=customer_ids, actionType__in=_SUPERSEDING_TYPES,
other_latest = {
cid: (last, last_id)
for cid, last, last_id in (
Action.objects.filter(
customer_id__in=customer_ids, actionType__in=_SUPERSEDING_TYPES,
)
.values("customer_id")
.annotate(last=Max("createdDT"), last_id=Max("id"))
.values_list("customer_id", "last", "last_id")
)
.values("customer_id")
.annotate(last=Max("createdDT"))
.values_list("customer_id", "last")
)
}
valid = {}
for customer_id, until in holds.items():
if until is None:
continue
coordinated_at = coordination_latest.get(customer_id)
if coordinated_at is None:
continue
activity_at = other_latest.get(customer_id)
if activity_at is not None and as_aware(activity_at) > as_aware(coordinated_at):
# پس از هماهنگی، اقدام دیگری ثبت شده ⇒ قرار منتفی است.
coordination = coordination_latest.get(customer_id)
if coordination is None:
continue
coordinated_at, coordinated_id = coordination
activity = other_latest.get(customer_id)
if activity is not None:
activity_at, activity_id = activity
newer = as_aware(activity_at) > as_aware(coordinated_at) or (
as_aware(activity_at) == as_aware(coordinated_at)
and activity_id > coordinated_id
)
if newer:
# پس از هماهنگی، اقدام دیگری ثبت شده ⇒ قرار منتفی است.
continue
valid[customer_id] = until
return valid
@@ -509,19 +527,25 @@ def _local_day_bounds(target_date):
)
def _pending_visit_customer_ids(*, since):
"""مشتریانی که **قرار بازدید** دارند و نباید در فهرست پیگیری بیایند.
def _pending_appointment_ids(*, since):
"""مشتریانی که **قرار بازدید/نصب** دارند و نباید در فهرست پیگیری بیایند.
قاعدهٔ محصولی: «اگر برای مشتری بازدید گذاشته شده، دیگر لازم نیست تماس
بگیریم». پس هر مشتری با اقدام ``bazdid + waite`` که تاریخش از ``since``
(آغاز روز مورد نظر) عقب‌تر نباشد، از فهرست پیگیری **حذف** می‌شود.
قاعدهٔ محصولی: «اگر برای مشتری بازدید یا نصب گذاشته شده، دیگر لازم نیست
تماس بگیریم». پس هر مشتری با اقدام ``bazdid``/``nasb`` و وضعیت ``waite`` که
تاریخش از ``since`` (آغاز روز مورد نظر) عقب‌تر نباشد، از فهرست پیگیری
**حذف** می‌شود.
چرا «نصب» هم؟ همان دو نوع، چرخهٔ بازدید را از نو شروع می‌کنند
(``_CYCLE_RESET_TYPES``) و قرار دستی را باطل می‌کنند؛ پس فهرست کار روزانه
هم باید همین قاعده را داشته باشد. قبلاً فقط ``bazdid`` دیده می‌شد و
«نصبِ برنامه‌ریزی‌شده» ردیف را باز می‌گذاشت.
چرا ``>= since`` و نه «هر بازدیدی»؟ بازدیدِ در انتظارِ بسیار قدیمی
(backlog بسته‌نشده) قرار فعال نیست؛ آن مشتری هنوز نیاز به تماس دارد.
"""
return set(
Action.objects.filter(
actionType=Action.ActionType.BAZDID,
actionType__in=_CYCLE_RESET_TYPES,
status=Action.Status.WAITE,
)
.filter(
@@ -547,8 +571,8 @@ def followups_on_date(target_date, *, now=None):
امروز یا آینده — را می‌بیند تا کارشناس بتواند با تقویم عقب/جلو برود.
خروجی: ``{customer_id: {منبع: تاریخ}}`` مرتب بر اساس زودترین ساعت موعد.
مشتریانی که قرار **بازدید** دارند، یا بازدیدشان از همان روز موعد به بعد
**انجام شده**، در خروجی نیستند (فهرست، «کارِ باقی‌مانده» است نه آرشیو).
مشتریانی که قرار **بازدید/نصب** دارند، یا بازدید/نصبشان از همان روز موعد به
بعد **انجام شده**، در خروجی نیستند (فهرست، «کارِ باقی‌مانده» است نه آرشیو).
"""
day_start, day_end = _local_day_bounds(target_date)
@@ -593,28 +617,33 @@ def followups_on_date(target_date, *, now=None):
if current is None or when < current:
bucket[SOURCE_COORDINATION] = when
# ۳) حذف مشتریانی که برایشان «بازدید» گذاشته شده است.
# ۳) حذف مشتریانی که برایشان «بازدید» یا «نصب» گذاشته شده است.
if result:
for customer_id in _pending_visit_customer_ids(since=day_start):
for customer_id in _pending_appointment_ids(since=day_start):
result.pop(customer_id, None)
# ۴) حذف مشتریانی که بازدیدشان از همان روزِ موعد به بعد **انجام شده** است.
# قاعدهٔ کسب‌وکار: اگر بازدید انجام شده، تماسِ آن روز لازم نیست. قبلاً فقط
# بازدیدِ «در انتظار» دیده می‌شد، پس مشتریِ بازدیدشده باز هم در فهرست
# ۴) حذف مشتریانی که بازدید/نصبشان از همان روزِ موعد به بعد **انجام شده** است.
# قاعدهٔ کسب‌وکار: اگر بازدید (یا نصب) انجام شده، تماسِ آن روز لازم نیست. قبلاً
# فقط بازدیدِ «در انتظار» دیده می‌شد، پس مشتریِ بازدیدشده باز هم در فهرست
# «نیازمند پیگیری امروز» می‌ماند (۹۲٪ ردیف‌های دادهٔ واقعی).
# لنگر از :func:`cycle_anchor_map` می‌آید — یعنی همان دو نوعی که چرخه را از نو
# شروع می‌کنند (``bazdid``/``nasb``). برای ``bazdid`` خروجی عیناً همان مقدار
# قبلی (``last_visit_map``) است، پس رفتار موجود تغییر نمی‌کند و «نصب» هم ردیف
# را می‌بندد. عمداً ``last_visit_map`` گشاد نشد: آن «تاریخ آخرین بازدید» است
# (ستون جدول، ``last_bazdid_dt`` و پیامک) و نباید نصب را بازدید حساب کند.
# فقط برای امروز/آینده اعمال می‌شود تا مرور روزهای گذشته (تقویم) سندِ کاریِ
# همان روز را با داده‌های بعدی بازنویسی نکند.
if result:
now = _now(now)
current_tz = timezone.get_current_timezone()
if target_date >= now.astimezone(current_tz).date():
visits = last_visit_map(now=now, customer_ids=list(result))
for customer_id, visited_at in visits.items():
anchors = cycle_anchor_map(now=now, customer_ids=list(result))
for customer_id, done_at in anchors.items():
bucket = result.get(customer_id)
if not bucket or visited_at is None:
if not bucket or done_at is None:
continue
due_day = min(bucket.values()).astimezone(current_tz).date()
if visited_at.astimezone(current_tz).date() >= due_day:
if done_at.astimezone(current_tz).date() >= due_day:
result.pop(customer_id, None)
return result
@@ -631,10 +660,10 @@ def overdue_followups(*, now=None, target_date=None):
* اگر مشتری هماهنگیِ **امروز یا آینده** داشته باشد، عقب‌افتاده نیست
(موعدش تازه است).
* اگر برایش **قرار بازدید** ثبت شده باشد، از فهرست خارج می‌شود — همان قاعدهٔ
:func:`followups_on_date` («بازدید گذاشته شده ⇒ تماس لازم نیست»).
* اگر بازدیدش **از همان موعد به بعد انجام شده** باشد، از فهرست خارج می‌شود
(پیگیری با بازدید بسته شده است).
* اگر برایش **قرار بازدید/نصب** ثبت شده باشد، از فهرست خارج می‌شود — همان
قاعدهٔ :func:`followups_on_date` («بازدید/نصب گذاشته شده ⇒ تماس لازم نیست»).
* اگر بازدید یا نصبش **از همان موعد به بعد انجام شده** باشد، از فهرست خارج
می‌شود (پیگیری با بازدید/نصب بسته شده است).
* اگر **قرار دستیِ معتبر** داشته باشد («تا تاریخ پیگیری نشود»)، از فهرست
خارج می‌شود؛ چون خودِ کاربر صریحاً خواسته تا آن تاریخ تماس نگیریم.
* هماهنگیِ لغوشده (``cancell``) اصلاً دیده نمی‌شود.
@@ -669,10 +698,10 @@ def overdue_followups(*, now=None, target_date=None):
if not overdue:
return {}
# حذف مشتریانی که برایشان بازدید گذاشته شده است (قرار بازدید از آن روز).
# «از آن روز» = زودترین موعدِ ازدست‌رفته؛ بازدیدی که بعد از آن گذاشته شده
# تکلیف مشتری را روشن کرده است.
pending_visits = _pending_visit_customer_ids(
# حذف مشتریانی که برایشان بازدید/نصب گذاشته شده است (قرار از آن روز).
# «از آن روز» = زودترین موعدِ ازدست‌رفته؛ بازدید/نصبی که بعد از آن گذاشته
# شده تکلیف مشتری را روشن کرده است.
pending_visits = _pending_appointment_ids(
since=_local_day_bounds(min(overdue.values()))[0]
)
for customer_id in pending_visits:
@@ -680,14 +709,16 @@ def overdue_followups(*, now=None, target_date=None):
current_tz = timezone.get_current_timezone()
# بازدیدِ **انجام‌شده** از همان موعد به بعد ⇒ این پیگیری انجام شده است.
# بازدید/نصبِ **انجام‌شده** از همان موعد به بعد ⇒ این پیگیری انجام شده است.
# قبلاً فقط بازدیدِ «در انتظار» دیده می‌شد و همین، فهرست «از روزهای قبل»
# را پر از مشتریانی می‌کرد که بازدیدشان ثبت شده بود (۸۸٪ دادهٔ واقعی).
visits = last_visit_map(now=now, customer_ids=list(overdue))
for customer_id, visited_at in visits.items():
if visited_at is None:
# لنگر همان ``cycle_anchor_map`` است (``bazdid``/``nasb``) تا «نصب» هم مثل
# بازدید پیگیری را ببندد.
anchors = cycle_anchor_map(now=now, customer_ids=list(overdue))
for customer_id, done_at in anchors.items():
if done_at is None:
continue
if visited_at.astimezone(current_tz).date() >= overdue[customer_id]:
if done_at.astimezone(current_tz).date() >= overdue[customer_id]:
overdue.pop(customer_id, None)
# «قرار دستیِ معتبر» یعنی کاربر صریحاً گفته «تا این تاریخ پیگیری نشود»؛
@@ -1008,14 +1039,24 @@ class ActionDeleteError(Exception):
"""حذف مجاز نیست؛ پیام برای نمایش به کاربر."""
def cancel_pending_sms_for_action(customer, *, now=None):
"""لغو پیامک‌های **ارسال‌نشدهٔ** «بازدید ثبت شد» همان مشتری.
def cancel_pending_sms_for_action(customer, *, action_id=None, sms_type=None, now=None):
"""لغو پیامک‌های **ارسال‌نشدهٔ** رویدادیِ همان مشتری («بازدید/نصب ثبت شد»).
چرا با ``action`` پیدا نمی‌کنیم؟ چون ``SMSQueue`` هیچ ارجاعی به رکورد اقدام
ندارد (فقط ``dedup_key`` متنی). برای جلوگیری از ارسال پیامکِ بازدیدی که
دیگر وجود ندارد، پیامک‌های معلق همان مشتری با نوع ``visit_created`` لغو
می‌شوند. پیامک‌های **ارسال‌شده** دست نمی‌خورند (سابقه است).
``sms_type`` (اختیاری): نوع پیامک مورد هدف؛ پیش‌فرض ``visit_created``
(رفتار قبلی، که حذف اقدام از آن استفاده می‌کند). برای «نصب» مقدار
``install_created`` داده می‌شود.
``action_id`` (اختیاری): فقط پیامک‌های همان رکورد اقدام را لغو می‌کند. کلید
یکتاسازی تریگر ``{sms_type}:{action_id}`` (یا با پسوند شماره) است؛ پس
«ویرایش یک رکورد از بازدید به نوع دیگر» نباید پیامک بازدیدِ *رکورد دیگر*
را از بین ببرد. پیش‌فرض (``None``) همهٔ پیامک‌های معلق همان نوع را لغو
می‌کند.
خروجی: تعداد رکوردهای لغوشده.
"""
# ایمپورت محلی: sms.models در زمان ایمپورت این ماژول ممکن است آماده نباشد.
@@ -1024,16 +1065,24 @@ def cancel_pending_sms_for_action(customer, *, now=None):
if customer is None or customer.pk is None:
return 0
now = _now(now)
sms_type = sms_type or SMSType.VISIT_CREATED
pending = (SMSQueue.Status.PENDING, SMSQueue.Status.APPROVED)
return (
query = (
SMSQueue.objects.filter(
customer_id=customer.pk,
sms_type=SMSType.VISIT_CREATED,
sms_type=sms_type,
status__in=pending,
)
.exclude(status=SMSQueue.Status.SENT)
.update(status=SMSQueue.Status.CANCELLED, updated_at=now)
)
if action_id is not None:
# ``startswith`` تنها کافی نیست: کلید اقدام ۷۷ پیشوندِ کلید اقدام ۷۷۵ هم
# هست. پس مرز «:» صریح گذاشته می‌شود (شکل ``…:{action_id}:{number}``).
base = "%s:%s" % (sms_type, action_id)
query = query.filter(
models.Q(dedup_key=base) | models.Q(dedup_key__startswith=base + ":")
)
return query.update(status=SMSQueue.Status.CANCELLED, updated_at=now)
def soft_delete_action(action, *, user=None, now=None):
@@ -1051,7 +1100,8 @@ def soft_delete_action(action, *, user=None, now=None):
۱. ``last_bazdid_dt`` بازمحاسبه می‌شود (کش کهنه نماند).
۲. قرار دستی اگر دیگر مرجعی نداشته باشد، خودکار بی‌اثر می‌شود
(قاعدهٔ «ستون، مرجع نیست»).
۳. پیامک‌های **ارسال‌نشده**ی همان مشتری لغو می‌شوند.
۳. پیامک‌های **ارسال‌نشده**ی رویدادیِ همان مشتری لغو می‌شوند (``visit_created``
و برای اقدام «نصب»، ``install_created``).
ممنوعیت: اقدامی که نظرسنجی ثبت‌شده دارد (``nazarDT`` پر است) حذف نمی‌شود؛
زیرا هیچ آرشیو/کپی‌ای وجود ندارد و گزارش رضایت‌سنجی برای همیشه تغییر می‌کرد.
@@ -1081,6 +1131,13 @@ def soft_delete_action(action, *, user=None, now=None):
if customer is not None:
refresh_last_bazdid(customer, reason=REASON_CANCEL, user=user, now=now)
cancelled = cancel_pending_sms_for_action(customer, now=now)
# اقدام «نصب» پیامک رویدادیِ خودش را هم دارد (نوع جدا)؛ حذف آن باید
# پیامک معلقش را هم لغو کند، وگرنه پیامک «نصب ثبت شد» بی‌مرجع می‌ماند.
if action.actionType == Action.ActionType.NASB:
from .models import SMSType
cancelled += cancel_pending_sms_for_action(
customer, sms_type=SMSType.INSTALL_CREATED, now=now,
)
else:
cancelled = 0