some improve
This commit is contained in:
1 parent
fa73a734a0
commit
2e5d0bcdd3
17 files changed
+1512
-226
No files matched your search
+115
-58
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user