improve bazdid 4
This commit is contained in:
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کننده'),
|
||||
),
|
||||
]
|
||||
@@ -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 %}";
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -319,6 +319,62 @@ visit_hold_until = max(مقدار فعلی، تاریخ جدید)
|
||||
|
||||
---
|
||||
|
||||
## ۶.۱ حذف یک اقدام (soft-delete)
|
||||
|
||||
دکمهٔ «حذف» فقط در صفحهٔ اقدامهای مشتری (`/panel/customer/<id>/action`) نمایش
|
||||
داده میشود و **هیچ رکوردی فیزیکی حذف نمیشود**؛ بهجای آن:
|
||||
|
||||
| ستون | مقدار |
|
||||
|---|---|
|
||||
| `status` | `cancell` |
|
||||
| `deleted_at` | زمان حذف |
|
||||
| `deleted_by` | کاربر حذفکننده |
|
||||
|
||||
### چرا `cancell` و نه ستون `is_deleted`؟
|
||||
|
||||
همهٔ محاسبات (`last_visit_map`، `cycle_anchor_map`، `appointment_sources_map`،
|
||||
`followups_on_date`، `overdue_followups`) از قبل فقط `waite`/`finish` را
|
||||
میبینند؛ و `ActionList*` و `ActionCount` هم با `status='waite'` فیلتر میشوند.
|
||||
پس همین یک مقدار، اقدام را از **همهٔ** خروجیها خارج میکند — بدون Manager
|
||||
سفارشی، بدون تغییر ایندکس پوشا و بدون دستزدن به ۳۱ کوئری.
|
||||
|
||||
### قاعدهٔ محصولی: اقدام دارای نظرسنجی حذف نمیشود
|
||||
|
||||
اگر `nazarDT` پر باشد (نظرسنجی ثبتشده)، حذف انجام **نمیشود**؛ چون هیچ
|
||||
آرشیو/کپیای وجود ندارد و گزارش رضایتسنجی برای همیشه تغییر میکرد. در UI
|
||||
دکمه به حالت قفلشده با برچسب «نظرسنجی» درمیآید.
|
||||
|
||||
### پیامدهای جانبی (در همان تراکنش)
|
||||
|
||||
۱. `last_bazdid_dt` بازمحاسبه میشود (کش کهنه نمیماند).
|
||||
۲. قرار دستی اگر دیگر مرجعی نداشته باشد، خودکار بیاثر میشود.
|
||||
۳. پیامکهای **ارسالنشدهٔ** «بازدید ثبت شد» همان مشتری به `cancelled` میروند
|
||||
(پیامکهای ارسالشده دست نمیخورند).
|
||||
|
||||
### اثر روی مصرفکنندهها
|
||||
|
||||
| بخش | اثر |
|
||||
|---|---|
|
||||
| `/panel/entezar/` (همهٔ نماها) | اقدام کاملاً نادیده گرفته میشود |
|
||||
| `/panel/ActionList/<u>/today` | از صف وظایف خارج میشود |
|
||||
| `ActionCount` داشبورد | بلافاصله کم میشود |
|
||||
| فهرست اقدامهای مشتری | بهطور پیشفرض پنهان (با `?status=cancell` قابل مشاهده برای حسابرسی) |
|
||||
| `/panel/customer/<id>/nazar/` | پنهان |
|
||||
| `/panel/ActionList/all` | پنهان |
|
||||
| گزارش وصول (`finance.py`) | آخرین اقدام وصول دیگر آن را نمیبیند |
|
||||
| داشبورد رضایتسنجی و گزارش روزانه | `deleted_at__isnull=True` |
|
||||
| صف پیامک | `CANCELLED` در `DONE_STATUSES` ⇒ از صف بیرون میرود |
|
||||
|
||||
### امنیت
|
||||
|
||||
- فقط **POST** مجاز است؛ درخواست GET ریدایرکت میشود و چیزی را عوض نمیکند.
|
||||
- `get_object_or_404(Action, id=..., customer=customer)` ⇒ حذف اقدام مشتری دیگر
|
||||
ممکن نیست (۴۰۴).
|
||||
- توکن CSRF در فرم اجباری است.
|
||||
- تأیید دومرحلهای پیش از حذف (نوع اقدام در متن تأیید نمایش داده میشود).
|
||||
|
||||
---
|
||||
|
||||
## ۷. سناریوهای تستشده
|
||||
|
||||
| # | سناریو | نتیجهٔ انتظار | وضعیت |
|
||||
|
||||
@@ -36,6 +36,7 @@ EXPECTED_URL_NAMES = {
|
||||
'customerNazar',
|
||||
'customerNewAction',
|
||||
'customerEditAction',
|
||||
'customerActionDelete',
|
||||
'customerDel',
|
||||
'customerFactors',
|
||||
'customerFactor',
|
||||
@@ -99,6 +100,7 @@ PARAMETERIZED = {
|
||||
'customerNazar': {'c_id': 1},
|
||||
'customerNewAction': {'c_id': 1},
|
||||
'customerEditAction': {'c_id': 1, 'action_id': 1},
|
||||
'customerActionDelete': {'c_id': 1, 'action_id': 1},
|
||||
'customerDel': {'c_id': 1},
|
||||
'customerFactors': {'c_id': 1},
|
||||
'customerFactor': {'c_id': 1, 'f_id': 1},
|
||||
|
||||
@@ -2106,3 +2106,263 @@ class TodayFollowupTests(TestCase):
|
||||
self._coordination(customer, -4)
|
||||
html = self.client.get(self.URL + '?view=uncoordinated').content.decode()
|
||||
self.assertNotIn('class="ez-overdue-alert"', html)
|
||||
|
||||
|
||||
class ActionSoftDeleteTests(TestCase):
|
||||
"""حذف (soft-delete) یک اقدام: status='cancell' + حسابرسی.
|
||||
|
||||
قواعد قفلشده:
|
||||
* حذف ⇒ اقدام از `entezar`، `ActionList` و داشبورد خارج میشود.
|
||||
* رکورد فیزیکی حذف **نمیشود** (سابقه برای حسابرسی میماند).
|
||||
* اقدام دارای نظرسنجی قابل حذف نیست.
|
||||
* پیامک ارسالنشدهٔ مرتبط لغو میشود.
|
||||
* `last_bazdid_dt` بازمحاسبه میشود.
|
||||
* GET بیاثر است؛ URL فقط POST میپذیرد.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = make_user('deleter', superuser=True)
|
||||
self.client.force_login(self.user)
|
||||
self.customer = make_customer('مشتری حذف')
|
||||
Customer.objects.filter(pk=self.customer.pk).update(
|
||||
installDate=timezone.localdate() - timedelta(days=500))
|
||||
self.customer.refresh_from_db()
|
||||
|
||||
def _action(self, **kwargs):
|
||||
defaults = dict(customer=self.customer, actionType='hamahangi',
|
||||
status='finish', actionDateTime=timezone.now(),
|
||||
finishDateTime=timezone.now())
|
||||
defaults.update(kwargs)
|
||||
return Action.objects.create(**defaults)
|
||||
|
||||
def _delete(self, action):
|
||||
return self.client.post(
|
||||
reverse('admin_panel:customerActionDelete',
|
||||
args=[self.customer.pk, action.pk]))
|
||||
|
||||
def _followup_ids(self):
|
||||
response = self.client.get('/panel/entezar/?view=today')
|
||||
return {o.id for o in response.context['customers']}
|
||||
|
||||
# ---------- رفتار اصلی ----------
|
||||
def test_delete_marks_cancelled_with_audit(self):
|
||||
action = self._action()
|
||||
self._delete(action)
|
||||
action.refresh_from_db()
|
||||
self.assertEqual(action.status, 'cancell')
|
||||
self.assertIsNotNone(action.deleted_at)
|
||||
self.assertEqual(action.deleted_by_id, self.user.pk)
|
||||
self.assertTrue(action.is_deleted)
|
||||
|
||||
def test_record_is_not_physically_deleted(self):
|
||||
action = self._action()
|
||||
pk = action.pk
|
||||
self._delete(action)
|
||||
self.assertTrue(Action.objects.filter(pk=pk).exists(),
|
||||
'رکورد باید برای حسابرسی باقی بماند')
|
||||
|
||||
def test_deleted_action_leaves_followup_list(self):
|
||||
action = self._action()
|
||||
self.assertIn(self.customer.id, self._followup_ids())
|
||||
self._delete(action)
|
||||
self.assertNotIn(self.customer.id, self._followup_ids())
|
||||
|
||||
def test_get_request_does_not_delete(self):
|
||||
action = self._action()
|
||||
response = self.client.get(reverse(
|
||||
'admin_panel:customerActionDelete',
|
||||
args=[self.customer.pk, action.pk]))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
action.refresh_from_db()
|
||||
self.assertIsNone(action.deleted_at)
|
||||
|
||||
def test_cannot_delete_twice(self):
|
||||
action = self._action()
|
||||
self._delete(action)
|
||||
# بار دوم باید خطا بدهد ولی کرش نکند
|
||||
response = self._delete(action)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
action.refresh_from_db()
|
||||
self.assertEqual(action.status, 'cancell')
|
||||
|
||||
# ---------- ممنوعیت نظرسنجی ----------
|
||||
def test_action_with_survey_cannot_be_deleted(self):
|
||||
action = self._action(nazarDT=timezone.now(), nazar='خوب', grade=20)
|
||||
self._delete(action)
|
||||
action.refresh_from_db()
|
||||
self.assertEqual(action.status, 'finish', 'نباید وضعیت عوض شود')
|
||||
self.assertIsNone(action.deleted_at)
|
||||
|
||||
def test_delete_blocked_message_shown(self):
|
||||
action = self._action(nazarDT=timezone.now(), nazar='خوب', grade=20)
|
||||
response = self._delete(action)
|
||||
messages_list = list(response.wsgi_request._messages)
|
||||
self.assertTrue(any('نظرسنجی' in str(m) for m in messages_list))
|
||||
|
||||
# ---------- پیامک ----------
|
||||
def test_pending_visit_sms_is_cancelled(self):
|
||||
from admin_panel.models import SMSQueue, SMSTemplate
|
||||
SMSTemplate.objects.update_or_create(
|
||||
sms_type='visit_created',
|
||||
defaults={'text': 'بازدید {name}', 'is_enabled': True})
|
||||
PhoneNumber.objects.create(customer=self.customer, title='موبایل',
|
||||
phone_number='09120000000')
|
||||
Customer.objects.filter(pk=self.customer.pk).update(phone1='09120000000')
|
||||
self.customer.refresh_from_db()
|
||||
|
||||
from admin_panel.sms.triggers import notify_visit_scheduled
|
||||
action = self._action(actionType='bazdid', status='waite')
|
||||
notify_visit_scheduled(self.customer, action, created_by=self.user)
|
||||
self.assertEqual(
|
||||
SMSQueue.objects.filter(customer=self.customer,
|
||||
sms_type='visit_created').count(), 1)
|
||||
|
||||
self._delete(action)
|
||||
self.assertEqual(
|
||||
SMSQueue.objects.filter(customer=self.customer,
|
||||
sms_type='visit_created',
|
||||
status=SMSQueue.Status.CANCELLED).count(), 1)
|
||||
|
||||
# ---------- بازمحاسبهٔ ستون ----------
|
||||
def test_last_bazdid_recalculated_after_delete(self):
|
||||
old_visit = self._action(actionType='bazdid',
|
||||
actionDateTime=at(-400), finishDateTime=at(-400))
|
||||
recent = self._action(actionType='bazdid',
|
||||
actionDateTime=at(-2), finishDateTime=at(-2))
|
||||
refresh_last_bazdid(self.customer, reason='create', user=self.user)
|
||||
self.customer.refresh_from_db()
|
||||
self.assertEqual(self.customer.last_bazdid_dt.date(), at(-2).date())
|
||||
|
||||
self._delete(recent)
|
||||
self.customer.refresh_from_db()
|
||||
self.assertEqual(self.customer.last_bazdid_dt.date(), at(-400).date())
|
||||
self.assertTrue(Action.objects.filter(pk=old_visit.pk).exists())
|
||||
|
||||
# ---------- سایر نماها ----------
|
||||
def test_deleted_action_hidden_from_customer_action_list(self):
|
||||
action = self._action()
|
||||
self._delete(action)
|
||||
html = self.client.get(
|
||||
'/panel/customer/%d/action' % self.customer.pk).content.decode()
|
||||
self.assertNotIn('action_id=\'%d\'' % action.pk, html)
|
||||
|
||||
def test_deleted_action_visible_with_explicit_filter(self):
|
||||
"""با فیلتر صریح status=cancell باید دیده شود (حسابرسی)."""
|
||||
action = self._action()
|
||||
self._delete(action)
|
||||
html = self.client.get(
|
||||
'/panel/customer/%d/action?status=cancell' % self.customer.pk
|
||||
).content.decode()
|
||||
self.assertIn('action_id=\'%d\'' % action.pk, html)
|
||||
|
||||
def test_deleted_action_hidden_from_actionlist_all(self):
|
||||
action = self._action(status='waite', forUser=self.user)
|
||||
self._delete(action)
|
||||
response = self.client.get(reverse('admin_panel:ActionListAll'))
|
||||
ids = {a.pk for a in response.context['actions']}
|
||||
self.assertNotIn(action.pk, ids)
|
||||
|
||||
def test_deleted_action_hidden_from_nazar_page(self):
|
||||
action = self._action(nazarDT=None)
|
||||
self._delete(action)
|
||||
response = self.client.get(
|
||||
'/panel/customer/%d/nazar/' % self.customer.pk)
|
||||
ids = {a.pk for a in response.context['actions']}
|
||||
self.assertNotIn(action.pk, ids)
|
||||
|
||||
|
||||
class ActionDeleteUITests(TestCase):
|
||||
"""دکمهٔ حذف روی صفحهٔ اقدامها: حضور، قفل نظرسنجی و اسکوپ."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = make_user('deleteUi', superuser=True)
|
||||
self.client.force_login(self.user)
|
||||
self.customer = make_customer('UI حذف')
|
||||
Customer.objects.filter(pk=self.customer.pk).update(
|
||||
installDate=timezone.localdate() - timedelta(days=500))
|
||||
self.customer.refresh_from_db()
|
||||
|
||||
def _action(self, **kwargs):
|
||||
defaults = dict(customer=self.customer, actionType='hamahangi',
|
||||
status='waite', actionDateTime=timezone.now())
|
||||
defaults.update(kwargs)
|
||||
return Action.objects.create(**defaults)
|
||||
|
||||
def test_delete_button_rendered_on_customer_action_page(self):
|
||||
action = self._action()
|
||||
html = self.client.get(
|
||||
'/panel/customer/%d/action' % self.customer.pk).content.decode()
|
||||
self.assertIn('/action/%d/delete' % action.pk, html)
|
||||
self.assertIn('ax-del is-danger', html)
|
||||
self.assertIn('method="post"', html)
|
||||
self.assertIn('csrfmiddlewaretoken', html)
|
||||
# تأیید پیش از حذف
|
||||
self.assertIn('confirm(', html)
|
||||
|
||||
def test_delete_button_absent_on_actionlist(self):
|
||||
action = self._action(forUser=self.user)
|
||||
html = self.client.get(
|
||||
'/panel/ActionList/%d/today' % self.user.pk).content.decode()
|
||||
self.assertNotIn('ax-del is-danger', html,
|
||||
'دکمهٔ حذف نباید در فهرست وظایف ظاهر شود')
|
||||
self.assertNotIn('/action/%d/delete' % action.pk, html)
|
||||
|
||||
def test_survey_action_shows_locked_state(self):
|
||||
self._action(nazarDT=timezone.now(), nazar='خوب', grade=20)
|
||||
html = self.client.get(
|
||||
'/panel/customer/%d/action' % self.customer.pk).content.decode()
|
||||
self.assertIn('ax-del is-locked', html)
|
||||
self.assertNotIn('ax-del is-danger', html)
|
||||
|
||||
def test_deleted_action_hidden_by_default(self):
|
||||
kept = self._action()
|
||||
gone = self._action()
|
||||
self.client.post(reverse('admin_panel:customerActionDelete',
|
||||
args=[self.customer.pk, gone.pk]))
|
||||
html = self.client.get(
|
||||
'/panel/customer/%d/action' % self.customer.pk).content.decode()
|
||||
self.assertIn("/action/%d/delete" % kept.pk, html)
|
||||
self.assertNotIn("/action/%d/delete" % gone.pk, html)
|
||||
|
||||
|
||||
class DeleteButtonDoesNotOpenModalTests(TestCase):
|
||||
"""کلیک روی «حذف» نباید مودال ویرایش را باز کند.
|
||||
|
||||
دکمهٔ حذف داخل کارتی است که کلاس ``actionBoxOpenB`` دارد و هندلرِ بازکردنِ
|
||||
مودال روی همان کارت بسته شده است؛ بدون محافظ، رویداد به والد میرسد و
|
||||
مودالِ ویرایش هم باز میشد. دو لایه محافظ گذاشته شده است:
|
||||
|
||||
۱) گاردِ ``closest('.ax-del-form, .ax-del')`` در هندلرِ ``actionBoxOpenB``
|
||||
۲) ``stopPropagation`` روی خودِ فرم/دکمهٔ حذف
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = make_user('noModalOnDelete', superuser=True)
|
||||
self.client.force_login(self.user)
|
||||
self.customer = make_customer('حذف بدون مودال')
|
||||
self.action = make_visit(self.customer, action_dt=at(-1),
|
||||
finish_dt=at(-1), action_type='hamahangi')
|
||||
self.html = self.client.get(
|
||||
'/panel/customer/%d/action' % self.customer.pk).content.decode()
|
||||
|
||||
def test_modal_handler_guards_delete_controls(self):
|
||||
self.assertIn("closest('.ax-del-form, .ax-del')", self.html)
|
||||
|
||||
def test_delete_controls_stop_propagation(self):
|
||||
self.assertIn('stopPropagation', self.html)
|
||||
self.assertRegex(
|
||||
self.html,
|
||||
r"\$\('\.ax-del-form, \.ax-del'\)"
|
||||
r"\s*\n\s*\.off\('\.axdel'\)"
|
||||
r"\s*\n\s*\.on\('click\.axdel mousedown\.axdel'",
|
||||
)
|
||||
|
||||
def test_guard_precedes_modal_open_logic(self):
|
||||
"""گارد باید پیش از ``isEditMode = true`` اجرا شود."""
|
||||
guard = self.html.index("closest('.ax-del-form, .ax-del')")
|
||||
opener = self.html.index('isEditMode = true;', guard - 900)
|
||||
self.assertLess(guard, opener)
|
||||
|
||||
def test_delete_form_has_csrf_and_post(self):
|
||||
self.assertIn('ax-del-form', self.html)
|
||||
self.assertIn('csrfmiddlewaretoken', self.html)
|
||||
Reference in new issue
Block a user