gozaresh vosol motalebat

This commit is contained in:
javad committed 2026-08-21 13:17:59 +03:30
1 parent 0d867367f9
commit 99a86c7073
4 files changed
+404 -1

No files matched your search

@@ -94,6 +94,9 @@
گزارش
</a>
<a href="{% url 'admin_panel:report_debtors_no_vosol' %}" style="padding-right: 10px;" class="navbar-item">
گزارش وصول مطالبات
</a>
<a href="{% url 'admin_panel:inventory_movements_report' %}" style="padding-right: 10px;" class="navbar-item">
گزارش جامع انبار
@@ -0,0 +1,288 @@
{% extends "./base.html" %}
{% load custom_tags %}
{% if perms.admin_panel.ViewReportMali %}
{% block bodyPage %}
<style>
/* استایل پایه کارت‌ها */
.risk-card {
transition: transform 0.2s, box-shadow 0.2s;
border-radius: 8px;
border: 1px solid #eee;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.risk-card:hover { transform: translateY(-3px); box-shadow: 0 4px 15px rgba(0,0,0,0.05); }
.border-danger { border-bottom: 4px solid #f14668 !important; }
.border-warning { border-bottom: 4px solid #ffdd57 !important; }
/* استایل جدول و باکس توضیحات */
.table-responsive-wrapper { width: 100%; overflow-x: auto; border-radius: 8px; border: 1px solid #eaeaea; }
.table th, .table td { vertical-align: middle !important; }
.desc-box {
max-width: 250px;
white-space: normal;
background: #f8fafc;
padding: 6px 8px;
border-radius: 4px;
border-right: 3px solid #3273dc;
font-size: 0.8rem;
line-height: 1.5;
margin-top: 6px;
}
/* استایل هدرهای قابل سورت */
.sortable-th { cursor: pointer; color: #363636; text-decoration: none !important; display: inline-flex; align-items: center; gap: 5px; }
.sortable-th:hover { color: #3273dc; }
.sort-icon { font-size: 0.8rem; }
.sort-icon.active { color: #3273dc; }
.sort-icon.inactive { color: #dbdbdb; }
/* ========================================= */
/* تنظیمات اختصاصی موبایل (Responsive Design) */
/* ========================================= */
@media screen and (max-width: 768px) {
.risk-card { padding: 1rem 0.5rem !important; }
.risk-card .title { font-size: 1.1rem !important; margin-bottom: 0.2rem !important; }
.risk-card .heading { font-size: 0.65rem !important; margin-bottom: 0.5rem !important; letter-spacing: 0; }
/* بهبود نمایش کنترل پنل در موبایل */
.control-panel-box { padding: 1rem !important; }
.control-panel-box .label { font-size: 0.8rem; margin-bottom: 0.3rem; }
/* کوچک کردن فونت‌های جدول برای جا شدن بهتر در موبایل */
.table th, .table td { font-size: 0.8rem; padding: 0.5rem 0.4rem !important; }
.desc-box { font-size: 0.75rem; min-width: 180px; }
.sortable-th { font-size: 0.75rem; }
/* دکمه‌های جدول در موبایل */
.action-btn span:last-child { display: none; } /* مخفی کردن متن دکمه، فقط آیکون می‌ماند */
.action-btn { padding: 0.5rem !important; }
}
</style>
<div class="container is-fluid mt-4 mb-6">
<div class="columns m-0 is-multiline">
<div class="column is-12">
<div class="mb-5">
<h2 class="title is-5 is-size-6-mobile mb-4 has-text-grey-dark">
<i class="fas fa-chart-line has-text-info"></i>
داشبورد هوشمند وصول مطالبات (رها شده)
</h2>
<!-- داشبورد آماری دقیق و تفکیک شده -->
<div class="columns is-multiline is-mobile">
<div class="column is-6-mobile is-3-desktop">
<div class="box risk-card border-danger has-text-centered p-4">
<p class="heading has-text-grey">مشتریان هرگز پیگیری نشده</p>
<p class="title is-size-4 has-text-dark">{{ stats.never_count }} <span class="is-size-6 has-text-grey">نفر</span></p>
</div>
</div>
<div class="column is-6-mobile is-3-desktop">
<div class="box risk-card border-danger has-text-centered p-4">
<p class="heading has-text-grey">مبلغ (هرگز پیگیری نشده)</p>
<p class="title is-size-4 has-text-danger-dark toCurrency">{{ stats.never_amount }}</p>
</div>
</div>
<div class="column is-6-mobile is-3-desktop">
<div class="box risk-card border-warning has-text-centered p-4">
<p class="heading has-text-grey">رها شده (بیش از {{ current_days }} روز)</p>
<p class="title is-size-4 has-text-dark">{{ stats.expired_count }} <span class="is-size-6 has-text-grey">نفر</span></p>
</div>
</div>
<div class="column is-6-mobile is-3-desktop">
<div class="box risk-card border-warning has-text-centered p-4">
<p class="heading has-text-grey">مبلغ (پیگیری رها شده)</p>
<p class="title is-size-4 has-text-danger-dark toCurrency">{{ stats.expired_amount }}</p>
</div>
</div>
</div>
</div>
<!-- کنترل پنل فیلترها و زمان (بهینه‌شده برای موبایل) -->
<div class="box mb-4 has-background-white-ter control-panel-box">
<form method="GET" action="">
<input type="hidden" name="sort" value="{{ current_sort }}">
<input type="hidden" name="dir" value="{{ current_dir }}">
<div class="columns is-multiline is-vcentered">
<div class="column is-12-mobile is-5-tablet">
<div class="field">
<label class="label">وضعیت پیگیری:</label>
<div class="control">
<div class="select is-fullwidth">
<select name="filter_type" class="is-size-7-mobile">
<option value="all" {% if current_filter == 'all' %}selected{% endif %}>همه نیازمند پیگیری</option>
<option value="never" {% if current_filter == 'never' %}selected{% endif %}>فقط هرگز پیگیری نشده‌ها</option>
<option value="expired" {% if current_filter == 'expired' %}selected{% endif %}>فقط پیگیری‌های رها شده</option>
</select>
</div>
</div>
</div>
</div>
<div class="column is-12-mobile is-4-tablet">
<div class="field">
<label class="label">بدون اقدام بیش از (روز):</label>
<div class="control">
<input type="number" name="days" class="input is-size-7-mobile" value="{{ current_days }}" min="1">
</div>
</div>
</div>
<div class="column is-12-mobile is-3-tablet">
<div class="field">
<!-- در موبایل لیبل خالی میگذاریم تا ارتفاع حفظ شود، در دسکتاپ مخفی است -->
<label class="label is-hidden-mobile">&nbsp;</label>
<div class="control">
<button type="submit" class="button is-info is-fullwidth">
<i class="fas fa-filter ml-2"></i> اعمال فیلتر
</button>
</div>
</div>
</div>
</div>
</form>
</div>
<!-- جدول داده‌ها -->
<div class="table-responsive-wrapper shadow-sm mb-4">
<table class="table m-auto has-text-centered is-bordered is-striped is-narrow is-fullwidth is-hoverable mb-0">
<thead class="has-background-light">
<tr>
<th style="width: 40px;">#</th>
<th>نام مشتری</th>
<th>شماره تماس</th>
<th>مانده کل حساب</th>
<!-- قابلیت Sort دوطرفه -->
<th>
<a class="sortable-th" href="?filter_type={{ current_filter }}&days={{ current_days }}&sort=amount&dir={% if current_sort == 'amount' and current_dir == 'desc' %}asc{% else %}desc{% endif %}">
مبلغ معوقه
<i class="fas fa-sort{% if current_sort == 'amount' %}-{{ current_dir }}{% endif %} sort-icon {% if current_sort == 'amount' %}active{% else %}inactive{% endif %}"></i>
</a>
</th>
<th>
<a class="sortable-th" href="?filter_type={{ current_filter }}&days={{ current_days }}&sort=oldest_date&dir={% if current_sort == 'oldest_date' and current_dir == 'desc' %}asc{% else %}desc{% endif %}">
تاریخ قدیمی‌ترین تاخیر
<i class="fas fa-sort{% if current_sort == 'oldest_date' %}-{{ current_dir }}{% endif %} sort-icon {% if current_sort == 'oldest_date' %}active{% else %}inactive{% endif %}"></i>
</a>
</th>
<th style="min-width: 230px;">
<a class="sortable-th" href="?filter_type={{ current_filter }}&days={{ current_days }}&sort=last_action&dir={% if current_sort == 'last_action' and current_dir == 'desc' %}asc{% else %}desc{% endif %}">
آخرین سابقه وصول
<i class="fas fa-sort{% if current_sort == 'last_action' %}-{{ current_dir }}{% endif %} sort-icon {% if current_sort == 'last_action' %}active{% else %}inactive{% endif %}"></i>
</a>
</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
{% for customer in page_obj %}
<tr>
<td>{{ forloop.counter0|add:page_obj.start_index }}</td>
<td>
<a target="_blank" href="{% url 'admin_panel:customerNew' customer.id %}" class="has-text-link has-text-weight-bold">
{{ customer.name_family }}
</a>
</td>
<td dir="ltr" class="has-text-weight-medium">
<div class="is-size-7-mobile">{{ customer.phone1|default:"-" }}</div>
{% if customer.phone2 %}<div class="is-size-7-mobile">{{ customer.phone2 }}</div>{% endif %}
</td>
<td>
<span class="toCurrency has-text-grey-dark is-size-7">{{ customer.net_debt }}</span>
</td>
<td>
<span class="toCurrency has-text-danger has-text-weight-bold is-size-6 is-size-7-mobile">
{{ customer.overdue_amount }}
</span>
<br>
<span class="tag is-light is-danger is-small mt-1">{{ customer.overdue_count }} فقره</span>
</td>
<td class="has-text-danger-dark has-text-weight-semibold is-size-7">
{{ customer.oldest_overdue_date | toJalali }}
</td>
<td style="text-align: right;">
{% if customer.last_vosol_date %}
<div class="has-text-grey-dark is-size-7 has-text-weight-semibold">
<i class="far fa-calendar-alt ml-1"></i> {{ customer.last_vosol_date | toJalali }}
</div>
<div class="desc-box has-text-grey">
{{ customer.last_vosol_desc|default:"(بدون توضیحات ثبت شده)" }}
</div>
{% else %}
<span class="tag is-danger is-light has-text-weight-bold is-size-7-mobile">هرگز پیگیری نشده</span>
{% endif %}
</td>
<td>
<!-- استفاده از کلاس action-btn برای ریسپانسیو شدن دکمه در موبایل -->
<a target="_blank" href="{% url 'admin_panel:customerAction' customer.id %}" class="button is-small is-info is-light has-text-weight-bold action-btn">
<span class="icon is-small"><i class="fas fa-phone-alt"></i></span>
<span>ثبت تماس</span>
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="8" class="has-text-grey p-5">
<i class="fas fa-check-circle has-text-success is-size-4 mb-2"></i><br>
با این تنظیمات و فیلترها، هیچ مشتری بدهکار رها شده‌ای یافت نشد.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- صفحه‌بندی -->
<div class="mt-4 is-flex is-justify-content-center">
<div class="buttons">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}&filter_type={{ current_filter }}&days={{ current_days }}&sort={{ current_sort }}&dir={{ current_dir }}" class="button is-light is-small-mobile">صفحه قبل</a>
{% endif %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}&filter_type={{ current_filter }}&days={{ current_days }}&sort={{ current_sort }}&dir={{ current_dir }}" class="button is-light is-small-mobile">صفحه بعد</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block jsEnd %}
<script>
$(document).ready(function() {
// فرمت‌کننده ایمن و رفع خطای NaN
$('.toCurrency').each(function() {
var rawValue = $(this).text().trim();
// بررسی می‌کنیم که متغیر خالی، نال یا None رشته‌ای نباشد
if(rawValue && rawValue !== 'None' && rawValue !== '') {
// حذف هر کاراکتری به جز اعداد و علامت منفی
var cleanValue = rawValue.replace(/[^\d-]/g, '');
if(!isNaN(cleanValue) && cleanValue !== '') {
$(this).text(Number(cleanValue).toLocaleString('en-US'));
} else {
$(this).text('0');
}
} else {
$(this).text('0');
}
});
});
</script>
{% endblock %}
{% endif %}
+1
View File
@@ -13,6 +13,7 @@ urlpatterns = [
path('report/', views.report, name='report'),
path('report/inventory-movements/', views.inventory_movements_report, name='inventory_movements_report'),
path('report/dashboard/', views.financial_dashboard, name='financial_dashboard'),
path('report/debtors-no-vosol/', views.report_debtors_no_vosol, name='report_debtors_no_vosol'),
path('report/rezayat_dashboard/', views.rezayat_dashboard, name='rezayat_dashboard'),
path('hesabdari/', views.hesabdari, name='hesabdari'),
path('hesabdari/sarresid_nashode/', views.hesabdari_residnashone, name='hesabdari_residnashone'),
+112 -1
View File
@@ -22,7 +22,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.http import HttpResponse, HttpResponseRedirect, HttpResponseBadRequest # بخشBadRequest جدید
from django.db import transaction, connection
from django.db.models import Count, Sum, Q, Avg, F, Subquery, OuterRef, IntegerField
from django.db.models import Count, Sum, Q, Avg, F, Subquery, OuterRef, IntegerField, Exists, Min, Max, Case, When
from django.contrib.auth.models import Permission, User
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required, permission_required
@@ -196,6 +196,117 @@ def report(request):
data = {'nahvePardakhat': nahvePardakhat, 'userHa':userHa}
return render(request, 'admin_panel/report.html', data)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewReportMali', raise_exception=True)
def report_debtors_no_vosol(request):
now = datetime.datetime.now()
# --- ۱. دریافت و اعتبارسنجی پارامترهای فیلتر از فرانت‌اند ---
try:
days_threshold = int(request.GET.get('days', 20))
if days_threshold < 0:
days_threshold = 20
except ValueError:
days_threshold = 20
filter_type = request.GET.get('filter_type', 'all') # all, never, expired
sort_field = request.GET.get('sort', 'amount') # amount, oldest_date, last_action
sort_dir = request.GET.get('dir', 'desc') # asc, desc
threshold_date = now - datetime.timedelta(days=days_threshold)
# --- ۲. ساخت Subquery های فوق‌سریع و ایمن ---
sq_debit = Article.objects.filter(customer=OuterRef('pk')).values('customer').annotate(s=Sum('debit')).values('s')
sq_credit = Article.objects.filter(customer=OuterRef('pk')).values('customer').annotate(s=Sum('credit')).values('s')
sq_overdue_amt = Sarresid.objects.filter(
customer=OuterRef('pk'), payStatus='false', sarresidDT__lte=now
).values('customer').annotate(s=Sum('price')).values('s')
sq_oldest_date = Sarresid.objects.filter(
customer=OuterRef('pk'), payStatus='false', sarresidDT__lte=now
).values('customer').annotate(min_dt=Min('sarresidDT')).values('min_dt')
sq_overdue_count = Sarresid.objects.filter(
customer=OuterRef('pk'), payStatus='false', sarresidDT__lte=now
).values('customer').annotate(c=Count('id')).values('c')
# استخراج آخرین سابقه اکشن وصول
latest_vosol_qs = Action.objects.filter(
customer=OuterRef('pk'), actionType='vosol'
).order_by('-createdDT')
# --- ۳. ساخت کوئری پایه (فقط مشتریان فعال و دارای معوقه) ---
base_qs = Customer.objects.filter(is_active=True).annotate(
t_debit=Coalesce(Subquery(sq_debit, output_field=IntegerField()), Value(0)),
t_credit=Coalesce(Subquery(sq_credit, output_field=IntegerField()), Value(0)),
overdue_amount=Coalesce(Subquery(sq_overdue_amt, output_field=IntegerField()), Value(0)),
overdue_count=Coalesce(Subquery(sq_overdue_count, output_field=IntegerField()), Value(0)),
oldest_overdue_date=Subquery(sq_oldest_date),
last_vosol_date=Subquery(latest_vosol_qs.values('createdDT')[:1]),
last_vosol_desc=Subquery(latest_vosol_qs.values('desc')[:1])
).annotate(
net_debt=F('t_debit') - F('t_credit')
).filter(
overdue_amount__gt=0 # شرط اساسی: فقط کسانی که معوقه سررسید گذشته دارند
)
# استخراج مشتریانی که نیازمند پیگیری هستند (هرگز پیگیری نشده‌اند یا موعد پیگیری‌شان گذشته است)
action_needed_qs = base_qs.filter(
Q(last_vosol_date__isnull=True) | Q(last_vosol_date__lt=threshold_date)
)
# --- ۴. محاسبه آمار دقیق (بدون خطا) برای داشبورد ---
# استفاده از Case/When برای جلوگیری کامل از خطای None در Aggregate
stats = action_needed_qs.aggregate(
never_count=Coalesce(Sum(Case(When(last_vosol_date__isnull=True, then=1), default=Value(0), output_field=IntegerField())), Value(0)),
never_amount=Coalesce(Sum(Case(When(last_vosol_date__isnull=True, then=F('overdue_amount')), default=Value(0), output_field=IntegerField())), Value(0)),
expired_count=Coalesce(Sum(Case(When(last_vosol_date__isnull=False, then=1), default=Value(0), output_field=IntegerField())), Value(0)),
expired_amount=Coalesce(Sum(Case(When(last_vosol_date__isnull=False, then=F('overdue_amount')), default=Value(0), output_field=IntegerField())), Value(0)),
)
# --- ۵. اعمال فیلتر کشویی (Dropdown) ---
if filter_type == 'never':
final_qs = action_needed_qs.filter(last_vosol_date__isnull=True)
elif filter_type == 'expired':
final_qs = action_needed_qs.filter(last_vosol_date__isnull=False)
else: # all
final_qs = action_needed_qs
# --- ۶. اعمال مرتب‌سازی (Sorting) پویا ---
if sort_field == 'amount':
order_expr = F('overdue_amount').desc() if sort_dir == 'desc' else F('overdue_amount').asc()
elif sort_field == 'oldest_date':
order_expr = F('oldest_overdue_date').desc(nulls_last=True) if sort_dir == 'desc' else F('oldest_overdue_date').asc(nulls_last=True)
elif sort_field == 'last_action':
order_expr = F('last_vosol_date').desc(nulls_last=True) if sort_dir == 'desc' else F('last_vosol_date').asc(nulls_first=True)
else:
order_expr = F('overdue_amount').desc()
final_qs = final_qs.order_by(order_expr)
# --- ۷. صفحه‌بندی ایمن ---
paginator = Paginator(final_qs, 50)
page_number = request.GET.get('page', 1)
try:
page_obj = paginator.page(page_number)
except (PageNotAnInteger, EmptyPage):
page_obj = paginator.page(1)
context = {
'page_obj': page_obj,
'page_name': 'debtors_no_vosol',
'stats': stats,
# پارامترهای کنترل پنل
'current_days': days_threshold,
'current_filter': filter_type,
'current_sort': sort_field,
'current_dir': sort_dir,
}
return render(request, 'admin_panel/report_debtors_no_vosol.html', context)
@login_required(login_url='/panel/p_login/')
@permission_required('admin_panel.ViewReportMali', raise_exception=True)
def inventory_movements_report(request):