improve bazdid 4

This commit is contained in:
javad committed 2026-10-21 16:34:13 +03:30
1 parent 8fd881e0ea
commit 825f2a5973
13 files changed
+585 -15

No files matched your search

@@ -97,7 +97,8 @@ class Command(BaseCommand):
# آمار نظرسنجی‌ها
nazar_stats = Action.objects.filter(
nazarDT__range=(today_start, today_end),
grade__gt=0
grade__gt=0,
deleted_at__isnull=True,
).aggregate(
avg_grade=Avg('grade'),
total_count=Count('id')
@@ -0,0 +1,26 @@
# Generated by Django 3.2.25 on 2026-10-21 12:31
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('admin_panel', '0079_customer_visit_hold'),
]
operations = [
migrations.AddField(
model_name='action',
name='deleted_at',
field=models.DateTimeField(blank=True, default=None, help_text='اگر پر باشد، این اقدام توسط کاربر حذف شده است (status=cancell).', null=True, verbose_name='زمان حذف'),
),
migrations.AddField(
model_name='action',
name='deleted_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_actions', to=settings.AUTH_USER_MODEL, verbose_name='حذف\u200cکننده'),
),
]
+19
View File
@@ -67,6 +67,25 @@ class Action(models.Model):
nazarDT = models.DateTimeField(null=True, blank=True, default=None)
grade = models.IntegerField(null=True, blank=True, default=None)
# --- حسابرسی «حذف» (soft-delete از طریق status='cancell') ---
# چرا ستون جدا؟ چون ``cancell`` از قبل معنای «لغو برنامه» را دارد و در گزارش‌ها
# هم استفاده می‌شود. با این دو ستون می‌توانیم «حذف توسط کاربر» را از «لغو
# عادی» تفکیک کنیم، بدون آنکه منطق محاسبات (که فقط waite/finish را می‌بیند)
# دست بخورد.
deleted_at = models.DateTimeField(
null=True, blank=True, default=None, verbose_name="زمان حذف",
help_text="اگر پر باشد، این اقدام توسط کاربر حذف شده است (status=cancell).",
)
deleted_by = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True,
related_name="deleted_actions", verbose_name="حذف‌کننده",
)
@property
def is_deleted(self):
"""آیا این اقدام با «حذف» کنار گذاشته شده است؟ (نه لغو عادی)"""
return self.deleted_at is not None
class Meta:
# ایندکس **پوشا (covering)** برای محاسبهٔ «آخرین بازدید»: هر سه ستونِ لازم
# برای فیلتر و تجمیع داخل خودِ ایندکس است، پس دیتابیس به جدول اصلی مراجعه
@@ -4,7 +4,7 @@
{% block BodyCustomer1 %}
{% include 'admin_panel/customerActionInc.html' %}
{% include 'admin_panel/customerActionInc.html' with show_delete=True %}
<!--
<div id="action" class="column is-12-mobile is-12-desktop ">
@@ -66,7 +66,8 @@
customer_hold='{% if action.customer.visit_hold_until %}{{ action.customer.visit_hold_until|date:"c" }}{% endif %}'
class="columns is-mobile is-multiline is-gapless actionBox {{ bgBox }}">
<div class="column is-12-mobile is-12-desktop actionBoxDesc actionBoxOpenB">
<div class="pr-4 pl-4 pb-4 pt-4">
<div class="pr-4 pl-4 pb-4 pt-4 ax-cardrow">
<span class="ax-cardrow-main">
{% ifequal action.status 'finish' %}
<img class="" style="width: 20px; height: 20px;" src="{% static 'admin_panel/icons/check.png' %}">
{% endifequal %}
@@ -81,6 +82,27 @@
<span class="is-bazdid">{% ifequal action.actionType 'nasb' %}نصب{% endifequal %}{% ifequal action.actionType 'bazdid' %}بازدید{% endifequal %}{% ifequal action.actionType 'vosol' %}وصول مطالبات{% endifequal %}{% ifequal action.actionType 'hamahangi' %}هماهنگی{% endifequal %}{% ifequal action.actionType 'inCall' %}تماس ورودی{% endifequal %}{% ifequal action.actionType 'outCall' %}تماس خروجی{% endifequal %}</span>
<span class="pr-3">{{ action.desc }}</span>
</span>
{% if show_delete %}
{% if action.nazarDT %}
<span class="ax-del is-locked" title="این اقدام دارای نظرسنجی ثبت‌شده است و قابل حذف نیست">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 1a5 5 0 0 0-5 5v3H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-9a2 2 0 0 0-2-2h-1V6a5 5 0 0 0-5-5zm-3 8V6a3 3 0 0 1 6 0v3H9z"/></svg>
<span class="ax-del-text">نظرسنجی</span>
</span>
{% else %}
<form method="post" class="ax-del-form"
action="{% url 'admin_panel:customerActionDelete' action.customer.id action.id %}">
{% csrf_token %}
<button type="submit" class="ax-del is-danger"
title="حذف این اقدام"
data-type="{% ifequal action.actionType 'nasb' %}نصب{% endifequal %}{% ifequal action.actionType 'bazdid' %}بازدید{% endifequal %}{% ifequal action.actionType 'vosol' %}وصول مطالبات{% endifequal %}{% ifequal action.actionType 'hamahangi' %}هماهنگی{% endifequal %}{% ifequal action.actionType 'inCall' %}تماس ورودی{% endifequal %}{% ifequal action.actionType 'outCall' %}تماس خروجی{% endifequal %}"
onclick="return confirm('این «' + (this.dataset.type || 'اقدام') + '» حذف شود؟\n\nاقدام از فهرست انتظار/وظایف خارج می‌شود و پیامک ارسال‌نشدهٔ مرتبط لغو می‌گردد. سابقه برای حسابرسی باقی می‌ماند.');">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2zm-3 6h12l-1 12a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 9zm4 3v8h2v-8h-2zm4 0v8h2v-8h-2z"/></svg>
<span class="ax-del-text">حذف</span>
</button>
</form>
{% endif %}
{% endif %}
</div>
</div>
<div class="column is-12-mobile is-12-desktop actionBoxOpenB">
@@ -166,6 +188,42 @@
</div>
<style>
/* ===== دکمهٔ «حذف» روی کارت اقدام (mobile first) ===== */
.ax-cardrow {
display: flex; align-items: flex-start; justify-content: space-between;
gap: .5rem; flex-wrap: nowrap;
}
.ax-cardrow-main { flex: 1 1 auto; min-width: 0; }
.ax-del-form { margin: 0; flex: 0 0 auto; }
.ax-del {
display: inline-flex; align-items: center; gap: .25rem;
padding: .2rem .45rem; border-radius: .375rem; border: 1px solid #e5e7eb;
background: #fff; color: #6b7280; font-size: .68rem; font-weight: 700;
cursor: pointer; line-height: 1.5; white-space: nowrap;
transition: background-color .15s ease, border-color .15s ease,
color .15s ease, box-shadow .15s ease;
}
.ax-del svg { width: 13px; height: 13px; fill: currentColor; flex: 0 0 auto; }
.ax-del:hover { background: #fef2f2; border-color: #fecaca; color: #b91c1c; }
.ax-del:focus-visible {
outline: 2px solid #fca5a5; outline-offset: 1px;
}
.ax-del.is-danger { border-color: #fee2e2; color: #dc2626; background: #fff; }
.ax-del.is-danger:hover {
background: #dc2626; border-color: #dc2626; color: #fff;
box-shadow: 0 1px 6px rgba(220, 38, 38, .25);
}
/* «نظرسنجی دارد» ⇒ دکمه غیرفعال (قاعدهٔ محصولی) */
.ax-del.is-locked {
cursor: not-allowed; color: #9ca3af; background: #f9fafb;
border-color: #e5e7eb;
}
.ax-del.is-locked:hover { background: #f9fafb; border-color: #e5e7eb; color: #9ca3af; }
@media (max-width: 480px) {
.ax-del-text { display: none; } /* در موبایل فقط آیکون، برای صرفه‌جویی فضا */
.ax-del { padding: .25rem; }
}
/* ===== مودال «ایجاد/ویرایش وظیفه» — mobile first =====
همهٔ کلاس‌ها با پیشوند ax- هستند تا با سایر بخش‌ها تداخل نکنند. */
#ActionBox .ax-card {
@@ -473,6 +531,16 @@
var holdAutoChecked = false;
var holdConfirmed = false;
// لایهٔ دوم محافظت: فرم «حذف» داخل کارتِ قابل‌کلیک است؛ رویداد را همان‌جا
// متوقف می‌کنیم تا به هندلرِ بازکردن مودالِ ویرایش نرسد.
// بایند «مستقیم» (نه delegated) چون این بلوک با هر ردیف تکرار می‌شود؛
// نام‌فضا (axdel) + off مانع از انباشته‌شدن هندلرها روی ردیف‌های تکراری می‌شود.
$('.ax-del-form, .ax-del')
.off('.axdel')
.on('click.axdel mousedown.axdel', function (e) {
e.stopPropagation();
});
// پیکر «تا تاریخ پیگیری نشود» را ریست می‌کند.
// نکته: pDatepicker یک Model برمی‌گرداند؛ اگر سلکتور هیچ المانی پیدا نکند
// مقدار undefined می‌شود. بدون این محافظ، یک ناهم‌خوانی نام کلاس باعث
@@ -596,7 +664,12 @@
showHoldConfirm();
});
$(".actionBoxOpenB").click(function(){
$(".actionBoxOpenB").click(function(event){
// کلیک روی کنترل‌های «حذف» نباید مودال ویرایش را باز کند.
// (دکمه داخل کارت است و رویداد به والد بالا می‌رفت.)
if ($(event.target).closest('.ax-del-form, .ax-del').length) {
return;
}
isEditMode = true;
$("#sabtBtn05").val("ویرایش");
var actionEditUrl = "{% url 'admin_panel:customerEditAction' 1111 2222 %}";
+1
View File
@@ -37,6 +37,7 @@ urlpatterns = [
path('customer/<int:c_id>/nazar/', views.customerNazar, name='customerNazar'),
path('customer/<int:c_id>/action/new', views.customerNewAction, name='customerNewAction'),
path('customer/<int:c_id>/action/<int:action_id>/edit', views.customerEditAction, name='customerEditAction'),
path('customer/<int:c_id>/action/<int:action_id>/delete', views.customerActionDelete, name='customerActionDelete'),
path('customer/<int:c_id>/del', views.customerDel, name='customerDel'),
path('customer/<int:c_id>/factors/', views.customerFactors, name='customerFactors'),
path('customer/<int:c_id>/factor/<int:f_id>', views.customerFactor, name='customerFactor'),
+47 -6
View File
@@ -12,9 +12,10 @@ from django.utils import timezone
from ..models import Action, Customer
from ..sms.triggers import notify_visit_scheduled
from ..visit_policy import (REASON_CANCEL, REASON_CREATE, REASON_EDIT,
as_aware, clear_visit_hold, default_hold_until,
normalize_finish_datetime, normalize_hold_until,
refresh_last_bazdid, set_visit_hold,
ActionDeleteError, as_aware, clear_visit_hold,
default_hold_until, normalize_finish_datetime,
normalize_hold_until, refresh_last_bazdid,
set_visit_hold, soft_delete_action,
validate_action_payload)
@@ -27,8 +28,12 @@ def customerAction(request, c_id):
filter_type = request.GET.get('actionType', 'all')
filter_status = request.GET.get('status', 'all')
# کوئری پایه برای اقدامات این مشتری
# کوئری پایه برای اقدامات این مشتری.
# اقدام‌های حذف‌شده (soft-delete) به‌طور پیش‌فرض نمایش داده نمی‌شوند؛ با
# فیلتر صریح `status=cancell` می‌توان آن‌ها را دید (برای حسابرسی).
actions = Action.objects.filter(customer=customer)
if filter_status != 'cancell':
actions = actions.filter(deleted_at__isnull=True)
# اعمال فیلتر بر اساس نوع اقدام (بازدید، نصب و ...)
if filter_type != 'all':
@@ -57,7 +62,9 @@ def customerAction(request, c_id):
@login_required(login_url='/panel/p_login/')
def customerNazar(request ,c_id):
customer = Customer.objects.get(id=c_id)
actions = Action.objects.filter(customer=customer, nazarDT__isnull=False).order_by("-createdDT")
actions = Action.objects.filter(
customer=customer, nazarDT__isnull=False, deleted_at__isnull=True,
).order_by("-createdDT")
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {'customer': customer, 'actions': actions, 'allUsers': allUsers}
return render(request, 'admin_panel/customer/nazar.html', data)
@@ -224,9 +231,43 @@ def customerEditAction(request ,c_id, action_id):
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
@login_required(login_url='/panel/p_login/')
def customerActionDelete(request, c_id, action_id):
"""«حذف» یک اقدام (soft-delete).
فقط POST مجاز است (تغییر وضعیت داده). اقدام فیزیکی حذف **نمی‌شود**؛
``status`` به ``cancell`` می‌رود و ``deleted_at``/``deleted_by`` پر می‌شوند.
چرا soft-delete؟ چون سابقهٔ CRM باید قابل حسابرسی بماند و منطق محاسبات
(entezar / ActionList / داشبورد) از قبل فقط ``waite``/``finish`` را می‌بیند؛
پس همین مقدار، اقدام را از همهٔ خروجی‌ها کنار می‌گذارد.
"""
customer_obj = get_object_or_404(Customer, id=c_id)
action = get_object_or_404(Action, id=action_id, customer=customer_obj)
back_url = '/panel/customer/%d/action' % customer_obj.pk
if request.method != 'POST':
# دسترسی مستقیم با GET نباید چیزی را عوض کند.
return redirect(back_url)
try:
cancelled = soft_delete_action(action, user=request.user)
except ActionDeleteError as error:
messages.error(request, str(error))
return redirect(back_url)
label = action.get_actionType_display() or 'اقدام'
message = '«%s» حذف شد و از فهرست‌های انتظار/وظایف خارج شد.' % label
if cancelled:
message += ' %d پیامک ارسال‌نشدهٔ مرتبط هم لغو شد.' % cancelled
messages.success(request, message)
return redirect(back_url)
@login_required(login_url='/panel/p_login/')
def ActionListAll(request):
actions = Action.objects.filter().order_by("-id")
# اقدام‌های حذف‌شده (soft-delete) در فهرست «همه» نمایش داده نمی‌شوند.
actions = Action.objects.filter(deleted_at__isnull=True).order_by("-id")
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {'actions': actions, 'allUsers': allUsers}
return render(request, 'admin_panel/ActionList.html', data)
+7 -4
View File
@@ -156,9 +156,10 @@ def report_debtors_no_vosol(request):
).values('customer').annotate(c=Count('id')).values('c')
# استخراج آخرین سابقه اکشن وصول
# نکته: اقدام حذف‌شده/لغوشده نباید «آخرین اقدام وصول» به‌حساب بیاید.
latest_vosol_qs = Action.objects.filter(
customer=OuterRef('pk'), actionType='vosol'
).order_by('-createdDT')
customer=OuterRef('pk'), actionType='vosol',
).exclude(status='cancell').order_by('-createdDT')
# --- ۳. ساخت کوئری پایه (فقط مشتریان فعال و دارای معوقه) ---
base_qs = Customer.objects.filter(is_active=True).annotate(
@@ -939,7 +940,8 @@ def rezayat_dashboard(request):
# دریافت تمامی نظرسنجی‌های ثبت شده معتبر (که تاریخ نظرسنجی دارند)
valid_surveys = Action.objects.filter(
nazarDT__isnull=False,
grade__in=[5, 10, 15, 20]
grade__in=[5, 10, 15, 20],
deleted_at__isnull=True,
)
# ۱. محاسبات شاخص‌های کلیدی (KPIs)
@@ -965,7 +967,8 @@ def rezayat_dashboard(request):
# ۴. واکشی لیست مشتریان نیازمند پیگیری (شامل متوسط ۱۵، ضعیف ۱۰ و بد ۵)
low_score_actions = Action.objects.filter(
nazarDT__isnull=False,
grade__in=[5, 10, 15] # <-- امتیاز ۱۵ (متوسط) نیز اضافه شد
grade__in=[5, 10, 15], # <-- امتیاز ۱۵ (متوسط) نیز اضافه شد
deleted_at__isnull=True,
).order_by('-nazarDT')
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
+3 -1
View File
@@ -40,7 +40,9 @@ SENT_STATUSES = [
# نیازمند اقدام را نشان می‌دهد؛ پیامکِ ارسال‌شده از صف بیرون می‌رود و سابقه‌اش در
# صفحهٔ «گزارش ارسال» (/panel/sms/report) دیده می‌شود. این کار جلوی حذفِ ناخواستهٔ
# تاریخچه از روی صف را می‌گیرد و صف را به فهرستِ کاریِ تمیز تبدیل می‌کند.
DONE_STATUSES = [SMSQueue.Status.SENT]
# پیامک «لغوشده» هم دیگر کاری برای انجام ندارد (مثلاً چون اقدامِ مرتبطش حذف شده)،
# پس از صف بیرون می‌رود.
DONE_STATUSES = [SMSQueue.Status.SENT, SMSQueue.Status.CANCELLED]
def _notice(url, code, **extra):
+86
View File
@@ -936,3 +936,89 @@ def normalize_finish_datetime(value, *, now=None):
"""تاریخ اتمام: اگر خالی بود، زمان ثبت قرار می‌گیرد (نه ``None``)."""
value = as_aware(value)
return value if value is not None else _now(now)
# ---------------------------------------------------------------------------
# حذف (soft-delete) یک اقدام
# ---------------------------------------------------------------------------
class ActionDeleteError(Exception):
"""حذف مجاز نیست؛ پیام برای نمایش به کاربر."""
def cancel_pending_sms_for_action(customer, *, now=None):
"""لغو پیامک‌های **ارسال‌نشدهٔ** «بازدید ثبت شد» همان مشتری.
چرا با ``action`` پیدا نمی‌کنیم؟ چون ``SMSQueue`` هیچ ارجاعی به رکورد اقدام
ندارد (فقط ``dedup_key`` متنی). برای جلوگیری از ارسال پیامکِ بازدیدی که
دیگر وجود ندارد، پیامک‌های معلق همان مشتری با نوع ``visit_created`` لغو
می‌شوند. پیامک‌های **ارسال‌شده** دست نمی‌خورند (سابقه است).
خروجی: تعداد رکوردهای لغوشده.
"""
# ایمپورت محلی: sms.models در زمان ایمپورت این ماژول ممکن است آماده نباشد.
from .models import SMSQueue, SMSType
if customer is None or customer.pk is None:
return 0
now = _now(now)
pending = (SMSQueue.Status.PENDING, SMSQueue.Status.APPROVED)
return (
SMSQueue.objects.filter(
customer_id=customer.pk,
sms_type=SMSType.VISIT_CREATED,
status__in=pending,
)
.exclude(status=SMSQueue.Status.SENT)
.update(status=SMSQueue.Status.CANCELLED, updated_at=now)
)
def soft_delete_action(action, *, user=None, now=None):
"""«حذف» یک اقدام: soft-delete با ``status='cancell'`` + حسابرسی.
چرا ``cancell`` و نه ستون ``is_deleted``؟ چون **تمام** محاسبات
(:func:`last_visit_map`، :func:`cycle_anchor_map`،
:func:`appointment_sources_map`، :func:`followups_on_date`،
:func:`overdue_followups`) از قبل فقط ``waite``/``finish`` را می‌بینند؛ پس
همین یک مقدار، اقدام را از همهٔ خروجی‌ها حذف می‌کند — بدون Manager سفارشی،
بدون تغییر ایندکس پوشا و بدون دست‌زدن به ۳۱ کوئری.
پیامدهای جانبی که همین‌جا انجام می‌شوند:
۱. ``last_bazdid_dt`` بازمحاسبه می‌شود (کش کهنه نماند).
۲. قرار دستی اگر دیگر مرجعی نداشته باشد، خودکار بی‌اثر می‌شود
(قاعدهٔ «ستون، مرجع نیست»).
۳. پیامک‌های **ارسال‌نشده**ی همان مشتری لغو می‌شوند.
ممنوعیت: اقدامی که نظرسنجی ثبت‌شده دارد (``nazarDT`` پر است) حذف نمی‌شود؛
زیرا هیچ آرشیو/کپی‌ای وجود ندارد و گزارش رضایت‌سنجی برای همیشه تغییر می‌کرد.
"""
if action is None or action.pk is None:
raise ActionDeleteError("اقدام معتبر نیست.")
if action.status == Action.Status.CANCELL and action.deleted_at is not None:
raise ActionDeleteError("این اقدام قبلاً حذف شده است.")
if action.nazarDT is not None:
raise ActionDeleteError(
"این اقدام دارای نظرسنجی ثبت‌شده است و قابل حذف نیست. "
"برای کنار گذاشتن آن، از ویرایش و تغییر وضعیت استفاده کنید."
)
now = _now(now)
customer = action.customer
with transaction.atomic():
Action.objects.filter(pk=action.pk).update(
status=Action.Status.CANCELL,
deleted_at=now,
deleted_by=user if getattr(user, "pk", None) else None,
)
action.status = Action.Status.CANCELL
action.deleted_at = now
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)
else:
cancelled = 0
return cancelled