import datetime import os from django.db import models from django.db.models import Sum, Q, Case, When, Value from django.shortcuts import redirect, render, get_object_or_404 from django.core.paginator import Paginator from django.utils import timezone from django.views.decorators.csrf import csrf_exempt from django.contrib import messages from django.contrib.auth.decorators import login_required, permission_required from persiantools.jdatetime import JalaliDate from ..models import (Customer, PhoneNumber, ProductCategory, Product, Factor, FactorIteam, Article, Sarresid, Action, CacheTable) from ..sms.balance import customer_balance from ..sms.phones import (normalize_primary_values, save_primary_normalized) from ..sms.triggers import notify_payment_thanks, notify_welcome from ..sms.rules import customer_toggle_map from ..visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED, appointment_sources_map, as_aware, followups_on_date, last_visit_map, overdue_followups, visit_due_at, visit_due_date, visit_period_months) # نماهای صفحهٔ «انتظار» (فیلتر ?view=). # پیش‌فرض = «نیازمند پیگیری امروز»: فهرست کاریِ روزانهٔ کارشناس (موعد تماس‌های # امروز). سایر نماها با کارت‌ها/دراپ‌داون «نوع قرار» در دسترس‌اند. # ``today`` یک نمای **مستقل** است: پایهٔ آن «عقب‌افتاده بودن» نیست — مشتری # سالمی هم که با او قرار تماس گذاشته شده باید در این فهرست دیده شود. ENTEZAR_VIEWS = ('today', 'uncoordinated', 'manual', 'scheduled', 'coordination', 'coordinated', 'all') ENTEZAR_DEFAULT_VIEW = 'today' # نمای «نیازمند پیگیری» — برای بررسی «آیا این نما فعال است؟» در قالب/ویو. ENTEZAR_FOLLOWUP_VIEW = 'today' # تعداد ردیف در هر صفحه. فهرست کاری روزانه با یک نگاه مرور می‌شود، پس صفحه‌بندی # دیرتر فعال شود. ENTEZAR_PAGE_SIZE = 100 # فیلتر «میزان تأخیر» (?delay=). مرزها با «مدت تأخیر» (روز) مقایسه می‌شوند. ENTEZAR_DELAYS = ('lt30', '30to90', '90to180', 'gt180') # ستون‌های قابل مرتب‌سازی: نام فیلد ⇒ جهت پیش‌فرضِ اولین کلیک. # ``oldest``/``newest``/``overdue`` مقادیر قدیمی‌اند و برای سازگاری با # بوکمارک‌های قبلی نگه داشته شده‌اند (به ``last_visit``/``overdue`` نگاشت می‌شوند). ENTEZAR_SORT_COLUMNS = { 'name': 'asc', 'status': 'asc', 'manual': 'desc', 'scheduled': 'desc', 'coordination': 'desc', 'last_visit': 'asc', 'overdue': 'desc', 'phone': 'asc', 'address': 'asc', } _ENTEZAR_LEGACY_SORTS = { 'oldest': ('last_visit', 'asc'), 'newest': ('last_visit', 'desc'), 'overdue': ('overdue', 'desc'), } from ._helpers import logger, _parse_jalali_date, _parse_phone_rows @csrf_exempt @login_required(login_url='/panel/p_login/') def customers(request): searchVal = request.GET.get('searchVal', '') searchOn = request.GET.get('searchOn', '') searchStatus = True full_path = request.get_full_path() # مشتریان غیرفعال هرگز نمایش داده نمی‌شوند — نه در فهرست کامل و نه در جستجو. all_customers = Customer.objects.filter(is_active=True) if searchOn == 'code': all_customers = all_customers.filter(idCode__contains=searchVal) elif searchOn == 'phone': all_customers = all_customers.filter(Q(phone1__contains=searchVal) | Q(phone2__contains=searchVal) | Q(phone3__contains=searchVal)) elif searchOn == 'name': all_customers = all_customers.filter(name_family__contains=searchVal) elif searchOn == 'address': all_customers = all_customers.filter(address__contains=searchVal) elif searchOn == 'laghv': all_customers = all_customers.filter(laghv_bazdid=True) else: searchStatus = False all_customers = all_customers.order_by('-create_date') paginator = Paginator(all_customers, 25) # Show 25 contacts per page. page_number = request.GET.get('page', None) all_customers = paginator.get_page(page_number) data = {'customersD': all_customers, 'searchStatus': searchStatus, 'full_path': full_path, 'searchVal': searchVal, 'searchOn':searchOn} return render(request, 'admin_panel/customers.html', data) @login_required(login_url='/panel/p_login/') def customersSearch(request, searchVal,searchOn): searchStatus = True # همان قاعدهٔ فهرست اصلی: مشتری غیرفعال در هیچ نتیجه‌ای نمایش داده نمی‌شود. all_customers = Customer.objects.filter(is_active=True) if searchOn == 'code': all_customers = all_customers.filter(idCode__contains=searchVal) elif searchOn == 'phone': all_customers = all_customers.filter(phone1__contains=searchVal) elif searchOn == 'name': all_customers = all_customers.filter(name_family__contains=searchVal) elif searchOn == 'address': all_customers = all_customers.filter(address__contains=searchVal) else: searchStatus = False all_customers = all_customers.order_by('-create_date') paginator = Paginator(all_customers, 25) # Show 25 contacts per page. page_number = request.GET.get('page', None) all_customers = paginator.get_page(page_number) data = {'customersD': all_customers, 'searchStatus': searchStatus} return render(request, 'admin_panel/customers.html', data) @login_required(login_url='/panel/p_login/') def customer(request ,c_id): customer = Customer.objects.get(id=c_id) categorys = ProductCategory.objects.all() factorha = Factor.objects.filter(customer__id = c_id) #all_prd = serializers.serialize("json", all_prda) #return JsonResponse(all_prd,safe = False) dateNow = datetime.datetime.now().date data = {'customer': customer, 'categorys': categorys, 'dateNow': dateNow, 'factorha': factorha} return render(request, 'admin_panel/customerID.html', data) @login_required(login_url='/panel/p_login/') def customerNew(request ,c_id): customer = Customer.objects.get(id=c_id) categorys = ProductCategory.objects.all() factorha = Factor.objects.filter(customer__id = c_id) #all_prd = serializers.serialize("json", all_prda) #return JsonResponse(all_prd,safe = False) dateNow = datetime.datetime.now().date data = {'customer': customer, 'categorys': categorys, 'dateNow': dateNow, 'factorha': factorha} return render(request, 'admin_panel/customerNew.html', data) @login_required(login_url='/panel/p_login/') def customerNew2(request ,c_id): customer = get_object_or_404(Customer, id=c_id, is_active=True) categorys = ProductCategory.objects.all() factorha = Factor.objects.filter(customer__id = c_id) debit = Article.objects.filter(customer=customer).aggregate(Sum('debit'))['debit__sum'] credit = Article.objects.filter(customer=customer).aggregate(Sum('credit'))['credit__sum'] mandehesabCs = 0 try: if (debit - credit) < 0: mandehesabCs = abs(debit - credit) except: pass #all_prd = serializers.serialize("json", all_prda) #return JsonResponse(all_prd,safe = False) dateNow = datetime.datetime.now().date sms_toggles = customer_toggle_map(customer) data = { 'customer': customer, 'categorys': categorys, 'dateNow': dateNow, 'factorha': factorha, 'mandehesabCs': mandehesabCs, 'sms_toggles': sms_toggles, } return render(request, 'admin_panel/customerNew2.html', data) @login_required(login_url='/panel/p_login/') def customerFactors(request, c_id): customer = get_object_or_404(Customer, id=c_id) categorys = ProductCategory.objects.all() # کوئری پایه برای دریافت همه فاکتورهای این مشتری factor_qs = Factor.objects.filter(customer=customer) # دریافت پارامترهای فیلتر و جستجو از آدرس URL search_query = request.GET.get('search_factor', '').strip() filter_status = request.GET.get('status', 'all') filter_type = request.GET.get('type', 'all') az_tarikh = request.GET.get('az_tarikh', '').strip() ta_tarikh = request.GET.get('ta_tarikh', '').strip() # ۱. اعمال فیلتر جستجو (شناسه فاکتور یا متن توضیحات) if search_query: if search_query.isdigit(): factor_qs = factor_qs.filter(models.Q(id=int(search_query)) | models.Q(desc__icontains=search_query)) else: factor_qs = factor_qs.filter(models.Q(desc__icontains=search_query)) # ۲. اعمال فیلتر وضعیت پرداخت و حذف نرم if filter_status != 'all': if filter_status == 'deleted': factor_qs = factor_qs.filter(is_active=False) else: factor_qs = factor_qs.filter(pay_status=filter_status, is_active=True) # ۳. اعمال فیلتر نوع فاکتور (نصب، خدمات، گارانتی) if filter_type != 'all': factor_qs = factor_qs.filter(FactorType=filter_type) # ۴. اعمال فیلتر تاریخ شروع (از تاریخ) if az_tarikh: az_gregorian = _parse_jalali_date(az_tarikh) if az_gregorian: az_datetime = datetime.datetime.combine(az_gregorian, datetime.time.min) factor_qs = factor_qs.filter(create_date__gte=az_datetime) # ۵. اعمال فیلتر تاریخ پایان (تا تاریخ) if ta_tarikh: ta_gregorian = _parse_jalali_date(ta_tarikh) if ta_gregorian: ta_datetime = datetime.datetime.combine(ta_gregorian, datetime.time.max) factor_qs = factor_qs.filter(create_date__lte=ta_datetime) # مرتب‌سازی بر اساس آخرین تاریخ ایجاد factorha = factor_qs.order_by('-create_date') dateNow = datetime.datetime.now().date data = { 'customer': customer, 'categorys': categorys, 'dateNow': dateNow, 'factorha': factorha, 'search_query': search_query, 'filter_status': filter_status, 'filter_type': filter_type, 'az_tarikh': az_tarikh, 'ta_tarikh': ta_tarikh } return render(request, 'admin_panel/customerIDFactor.html', data) @login_required(login_url='/panel/p_login/') def customerFactor(request ,c_id, f_id): customer = Customer.objects.get(id=c_id) categorys = ProductCategory.objects.all() factorO = Factor.objects.get(customer=customer, id=f_id) fiteam = FactorIteam.objects.filter(factor=factorO) #all_prd = serializers.serialize("json", all_prda) #return JsonResponse(all_prd,safe = False) dateNow = datetime.datetime.now().date data = {'customer': customer, 'categorys': categorys, 'dateNow': dateNow, 'factorO': factorO, 'fiteam': fiteam} return render(request, 'admin_panel/customerFactor.html', data) def productsJson(request): from django.core import serializers from django.http import JsonResponse all_prd = Product.objects.all() all_prd = serializers.serialize("json", all_prd) return JsonResponse(all_prd,safe = False) def ActionJson(request, action_id): from django.core import serializers from django.http import JsonResponse AcJson = Action.objects.filter(id=action_id) AcJson = serializers.serialize("json", AcJson) return JsonResponse(AcJson,safe = False) def CustomerJson(request, c_id): from django.core import serializers from django.http import JsonResponse customerJson = Customer.objects.filter(id=c_id) customerJson = serializers.serialize("json", customerJson) return JsonResponse(customerJson,safe = False) @login_required(login_url='/panel/p_login/') def customersAdd(request): an_name = request.POST.get('an_name', '') an_address = request.POST.get('an_address', '') installDate = request.POST.get('installDate', '') idnCode = request.POST.get('idnCode', '') # آرایه‌های عنوان/شمارهٔ فرم + ایندکس ردیف‌های علامت‌خورده به‌عنوان «اصلی» valid_phones, phones_list, primary_indices = _parse_phone_rows(request) year, month, day = map(int, installDate.split('/')) installDate_gregorian = JalaliDate(year, month, day).to_gregorian() newCs = Customer() newCs.name_family = an_name newCs.address = an_address newCs.installDate = installDate_gregorian newCs.idCode = idnCode # حفظ سازگاری با کدهای قدیمی (ریختن ۳ شماره اول در فیلدهای قدیمی) newCs.phone1 = valid_phones[0]['phone'] if len(valid_phones) > 0 else '' newCs.phone2 = valid_phones[1]['phone'] if len(valid_phones) > 1 else '' newCs.phone3 = valid_phones[2]['phone'] if len(valid_phones) > 2 else '' # شماره‌های «اصلی» پیامک، بر مبنای مقدارِ نرمال‌شدهٔ خودِ شماره‌ها (پایدار در بازسازی‌ها). newCs.sms_primary_phones = normalize_primary_values( phones_list[i] for i in primary_indices if i < len(phones_list) ) newCs.save() # ذخیره شماره‌ها به صورت نامحدود در جدول PhoneNumber for item in valid_phones: PhoneNumber.objects.create( customer=newCs, title=item['title'], phone_number=item['phone'], ) # پیامک خوش‌آمدگویی (در صورت فعال بودن قالب و تنظیم مشتری) notify_welcome(newCs, created_by=request.user) return redirect('/panel/customers/') @login_required(login_url='/panel/p_login/') def customerEdit(request, c_id): # این مسیر فقط endpoint فرم ویرایش است؛ دسترسی مستقیم با GET نباید ۵۰۰ بدهد. if request.method != 'POST': return redirect('/panel/customer/' + str(c_id)) an_name = request.POST.get('an_name', '') an_address = request.POST.get('an_address', '') installDate = request.POST.get('installDate', '') idnCode = request.POST.get('idnCode', '') laghv_bazdid = request.POST.get('laghv_bazdid', False) # آرایه‌های عنوان/شمارهٔ فرم + ایندکس ردیف‌های علامت‌خورده به‌عنوان «اصلی» valid_phones, phones_list, primary_indices = _parse_phone_rows(request) year, month, day = map(int, installDate.split('/')) installDate_gregorian = JalaliDate(year, month, day).to_gregorian() newCs = get_object_or_404(Customer, id=c_id) newCs.name_family = an_name newCs.address = an_address newCs.installDate = installDate_gregorian newCs.idCode = idnCode newCs.laghv_bazdid = True if laghv_bazdid == 'true' else False # فقط وقتی فرم فیلد توضیحات را فرستاده باشد مقدارش عوض می‌شود؛ فرم‌های قدیمی # که این فیلد را ندارند نباید یادداشت‌های پاک‌سازی را پاک کنند. if 'notes' in request.POST: newCs.notes = request.POST.get('notes', '') # آپدیت فیلدهای قدیمی جهت حفظ سازگاری (تنها وقتی شماره‌ای ارسال شده باشد) if valid_phones: newCs.phone1 = valid_phones[0]['phone'] if len(valid_phones) > 0 else '' newCs.phone2 = valid_phones[1]['phone'] if len(valid_phones) > 1 else '' newCs.phone3 = valid_phones[2]['phone'] if len(valid_phones) > 2 else '' newCs.save() # بروزرسانی جدول شماره‌ها فقط در صورت ارسال شماره‌های جدید. # اگر فرم شماره‌ای نفرستاده باشد (فیلدهای غیرفعال)، شماره‌های فعلی دست‌نخورده می‌مانند. if valid_phones: PhoneNumber.objects.filter(customer=newCs).delete() for item in valid_phones: PhoneNumber.objects.create( customer=newCs, title=item['title'], phone_number=item['phone'], ) # اگر فرم ویرایش ردیف‌های «اصلی» را مشخص کرده باشد، همان‌ها ذخیره می‌شوند # (مبتنی بر خودِ شماره، نه ایندکس رکورد). if primary_indices: save_primary_normalized( newCs, [phones_list[i] for i in primary_indices if i < len(phones_list)], ) return redirect('/panel/customer/'+str(c_id)) @login_required(login_url='/panel/p_login/') def entezar(request): """فهرست «منتظر بازدید» + تفکیک بر اساس **نوع قرار**. منبع حقیقت تاریخ بازدید ``visit_policy.last_visit_map`` است و منبع قرارها ``appointment_sources_map`` که سه منبع مستقل دارد: * ``manual`` — قرار دستی روی مشتری (تعویق از فرم هماهنگی) * ``scheduled`` — اقدام «بازدید» با تاریخ آینده * ``coordination`` — اقدام «هماهنگی» با تاریخ پیگیری در آینده هر فیلتر فقط منبع خودش را نشان می‌دهد (یک مشتری می‌تواند چند منبع داشته باشد و در چند فیلتر دیده شود). فیلتر با ``?view=`` و قابل ترکیب با جست‌وجو. """ searchVal = request.GET.get('searchVal', '') searchOn = request.GET.get('searchOn', '') view = (request.GET.get('view') or ENTEZAR_DEFAULT_VIEW).strip().lower() if view not in ENTEZAR_VIEWS: view = ENTEZAR_DEFAULT_VIEW # مقدار نامعتبر ⇒ پیش‌فرض، بدون خطا delay = (request.GET.get('delay') or '').strip().lower() if delay not in ENTEZAR_DELAYS: delay = '' # '' یعنی بدون فیلتر تأخیر # مرتب‌سازی: ``?sort=<ستون>&dir=asc|desc``؛ مقادیر قدیمی هم پذیرفته می‌شوند. # نکته: اگر ``dir`` صریح آمده باشد، مقدم است — وگرنه کلیک روی سرستون # «تأخیر» با جهت صعودی، به‌خاطر نگاشت قدیمی، نادیده گرفته می‌شد. raw_sort = (request.GET.get('sort') or '').strip().lower() raw_dir = (request.GET.get('dir') or '').strip().lower() if raw_sort in ENTEZAR_SORT_COLUMNS: sort_field = raw_sort if raw_dir in ('asc', 'desc'): sort_dir = raw_dir else: sort_dir = 'desc' if raw_sort in _ENTEZAR_LEGACY_SORTS else \ ENTEZAR_SORT_COLUMNS[raw_sort] elif raw_sort in _ENTEZAR_LEGACY_SORTS: sort_field, sort_dir = _ENTEZAR_LEGACY_SORTS[raw_sort] else: sort_field, sort_dir = _ENTEZAR_LEGACY_SORTS['oldest'] searchStatus = True full_path = request.get_full_path() now = timezone.now() due_cut = visit_due_at(now=now) # دوره فقط یک بار خوانده می‌شود (جلوگیری از N+1 در حلقهٔ محاسبهٔ تأخیر). period_months = visit_period_months() # مرز تاریخ به وقت محلی (نه UTC) تا صفحه و اسکنر پیامک یک جواب بدهند. current_tz = timezone.get_current_timezone() today_date = now.astimezone(current_tz).date() due_cut_date = due_cut.astimezone(current_tz).date() # تاریخ نمای «نیازمند پیگیری»: از تقویم شمسی می‌آید (``?fdate=1405/08/29``). # خالی/نامعتبر ⇒ امروز. مقدار گذشته و آینده هر دو مجازند. followup_date_raw = (request.GET.get('fdate') or '').strip() followup_date = _parse_jalali_date(followup_date_raw) or today_date followup_date_jalali = JalaliDate(followup_date).strftime('%Y/%m/%d') is_followup_view = view == ENTEZAR_FOLLOWUP_VIEW visits = last_visit_map(now=now) sources = appointment_sources_map(now=now) # جست‌وجو: نام، آدرس، تلفن (همه شماره‌ها) و کد شناسایی. def _apply_search(queryset): """فیلتر جست‌وجو؛ فیلد نامعتبر ⇒ بدون فیلتر (سازگاری با رفتار قبلی).""" if not searchVal: return queryset if searchOn == 'address': return queryset.filter(address__contains=searchVal) if searchOn == 'name': return queryset.filter(name_family__contains=searchVal) if searchOn == 'phone': return queryset.filter( Q(phone1__contains=searchVal) | Q(phone2__contains=searchVal) | Q(phone3__contains=searchVal) ) if searchOn == 'code': return queryset.filter(idCode__contains=searchVal) return queryset searchStatus = bool(searchVal) and searchOn in ( 'address', 'name', 'phone', 'code') # پایهٔ فهرست انتظار: فقط مشتریانی که دوره‌شان گذشته (کارت‌های آمار). customers_objj = Customer.objects.filter( laghv_bazdid=False, name_family__isnull=False, installDate__lte=due_cut_date, ) customers_objj = _apply_search(customers_objj) # مشتریان عقب‌افتاده: بدون بازدید معتبر، یا بازدید معتبرشان پیش از مقطع دوره. stale_ids = [ customer_id for customer_id in customers_objj.values_list('id', flat=True) if visits.get(customer_id) is None or visits[customer_id] <= due_cut ] # تفکیک بر اساس **منبعِ** قرار: هر مشتری می‌تواند صفر تا سه منبع داشته باشد # (قرار دستی، بازدید برنامه‌ریزی‌شده، هماهنگی). قبلاً «هماهنگی» با «بازدید» # در یک گروه جمع می‌شد و در نمای «بازدید برنامه‌ریزی‌شده» دیده می‌شد؛ حالا # هر منبع گروه نمایشیِ خودش را دارد. with_manual, with_scheduled, with_coordination = set(), set(), set() for customer_id in stale_ids: customer_sources = sources.get(customer_id) or {} if SOURCE_MANUAL in customer_sources: with_manual.add(customer_id) if SOURCE_SCHEDULED in customer_sources: with_scheduled.add(customer_id) if SOURCE_COORDINATION in customer_sources: with_coordination.add(customer_id) coordinated_ids = [cid for cid in stale_ids if cid in ( with_manual | with_scheduled | with_coordination)] selected_map = { # نداشتن هیچ قرار 'uncoordinated': [cid for cid in stale_ids if cid not in ( with_manual | with_scheduled | with_coordination)], # هر مشتری که این منبع را دارد (حتی اگر منبع دیگری هم داشته باشد) 'manual': [cid for cid in stale_ids if cid in with_manual], 'scheduled': [cid for cid in stale_ids if cid in with_scheduled], 'coordination': [cid for cid in stale_ids if cid in with_coordination], # اجتماع هر سه منبع 'coordinated': coordinated_ids, 'all': stale_ids, } # ---------- نمای «نیازمند پیگیری» (یک تاریخ مشخص) ---------- # این نما **مستقل** از «عقب‌افتاده» است: مشتری سالمی که با او قرار تماس # گذاشته شده هم باید دیده شود. قاعدهٔ حذف «بازدید» در خود # ``followups_on_date`` اعمال می‌شود (مشتریِ دارای قرار بازدید نمی‌آید). today_followups = followups_on_date(today_date, now=now) # پیگیری‌های عقب‌افتاده: موعدشان گذشته و هنوز تعیین تکلیف نشده‌اند. # فقط در نمای پیگیری محاسبه می‌شود (کوئری اضافه برای بقیهٔ نماها لازم نیست). overdue_map = overdue_followups(now=now, target_date=today_date) \ if is_followup_view else {} overdue_oldest = min(overdue_map.values()) if overdue_map else None # فیلتر «فقط عقب‌افتاده‌ها» (?missed=1) — همان نما با منبعِ دیگر. show_missed = is_followup_view and \ (request.GET.get('missed') or '').strip().lower() in ('1', 'true', 'on') if is_followup_view: if show_missed: # تاریخِ موعدِ ازدست‌رفته به‌صورت datetime تا با بقیهٔ مسیرها یکسان باشد. followups = { cid: {SOURCE_COORDINATION: as_aware(day)} for cid, day in overdue_map.items() } elif followup_date == today_date: followups = today_followups else: followups = followups_on_date(followup_date, now=now) else: followups = {} def _followup_ids(mapping): """شناسه‌های واجد شرایط (فعال/لغو‌نشده/دارای نام + جست‌وجو) با ترتیب زمانی.""" if not mapping: return [] eligible = _apply_search( Customer.objects.filter( id__in=list(mapping), is_active=True, laghv_bazdid=False, name_family__isnull=False, ) ) allowed = set(eligible.values_list('id', flat=True)) return sorted( (cid for cid in mapping if cid in allowed), key=lambda cid: (min(mapping[cid].values()), cid), ) followup_ids = _followup_ids(followups) if is_followup_view else [] # شمارنده‌ها از همان مسیرِ فهرست می‌آیند تا «عدد هشدار = تعداد ردیف‌ها» بماند # (مشتری غیرفعال/لغو‌شده/بی‌نام یا فیلترشده شمرده نمی‌شود). today_followup_count = len(_followup_ids(today_followups)) overdue_followup_count = len(_followup_ids( {cid: {SOURCE_COORDINATION: as_aware(day)} for cid, day in overdue_map.items()} )) selected_map[ENTEZAR_FOLLOWUP_VIEW] = followup_ids selected = selected_map[view] # برای نمای پیگیری، فهرست مشتریان از همان شناسه‌های تأییدشده می‌آید # (ممکن است در «عقب‌افتاده» نباشند). if is_followup_view: customers_objj = _apply_search( Customer.objects.filter(id__in=followup_ids) ) # «مدت تأخیر» = از چه زمانی بازدید عقب افتاده است (به روز). # مبنای محاسبه «موعد بازدید» است، نه خود بازدید: موعد = بازدید + دوره. # این‌طور مشتریِ تازه‌معوق هم در بازهٔ کوتاه قرار می‌گیرد و عدد معنا پیدا می‌کند. # محاسبه روی کلِ نمای جاری انجام می‌شود تا شمارنده‌های بازه‌ها با فیلتر # «میزان تأخیر» هم‌خوان باشند (اول نمای جاری، بعد فیلتر تأخیر). today = today_date overdue_days = {} for customer_id in selected: anchor = visits.get(customer_id) if anchor is not None: due = visit_due_date(anchor, months=period_months) base = due.astimezone(timezone.get_current_timezone()).date() else: # بدون بازدید: مقطع دوره (تاریخ نصب خیلی قدیمی، عدد را بی‌معنا می‌کند). base = due_cut_date overdue_days[customer_id] = max((today - base).days, 0) # آمار «تأخیر» روی نمای جاری (قبل از اعمال فیلتر تأخیر). buckets = {'lt30': 0, '30to90': 0, '90to180': 0, 'gt180': 0} for days in overdue_days.values(): if days < 30: buckets['lt30'] += 1 elif days < 90: buckets['30to90'] += 1 elif days < 180: buckets['90to180'] += 1 else: buckets['gt180'] += 1 # اعمال فیلتر «میزان تأخیر». delay_ranges = { 'lt30': (0, 30), '30to90': (30, 90), '90to180': (90, 180), 'gt180': (180, None), } # فیلتر «میزان تأخیر» فقط برای نمای «عقب‌افتاده» معنا دارد؛ در نمای # «پیگیری امروز» مشتری ممکن است اصلاً تأخیری نداشته باشد. if delay and not is_followup_view: low, high = delay_ranges[delay] selected = [ cid for cid in selected if overdue_days.get(cid, 0) >= low and (high is None or overdue_days.get(cid, 0) < high) ] # ---------- مرتب‌سازی (روی همهٔ ستون‌ها) ---------- # یک کوئری سبک برای فیلدهای Customer که برای مرتب‌سازی لازم است. info = { row['id']: row for row in Customer.objects.filter(id__in=selected).values( 'id', 'name_family', 'phone1', 'address') } if selected else {} def _source_date(cid, key): return (sources.get(cid) or {}).get(key) def _sort_value(cid): """مقدار مرتب‌سازی برای هر ستون (تاریخ‌ها aware یا None).""" if sort_field == 'name': return (info.get(cid, {}).get('name_family') or '') if sort_field == 'phone': return (info.get(cid, {}).get('phone1') or '') if sort_field == 'address': return (info.get(cid, {}).get('address') or '') if sort_field == 'status': # ترتیب منطقی: بدون قرار ← قرار دستی ← بازدید ← هماهنگی ← چندقراره entry = sources.get(cid) or {} if not entry: return 0 if len(entry) > 1: return 4 if SOURCE_MANUAL in entry: return 1 if SOURCE_SCHEDULED in entry: return 2 return 3 if sort_field == 'manual': return _source_date(cid, SOURCE_MANUAL) if sort_field == 'scheduled': return _source_date(cid, SOURCE_SCHEDULED) if sort_field == 'coordination': return _source_date(cid, SOURCE_COORDINATION) if sort_field == 'overdue': return overdue_days.get(cid, 0) # last_visit: مشتریِ بدون بازدید، قدیمی‌ترین حساب می‌شود. return visits.get(cid) or due_cut if selected: if is_followup_view and not raw_sort: # در نمای پیگیری، ترتیب طبیعی «زودترین موعد پیگیری» است # (ترتیب از ``_followup_ids`` آمده) — نه قدیمی‌ترین بازدید. ordered_ids = list(selected) else: values = {cid: _sort_value(cid) for cid in selected} with_value = [cid for cid in selected if values[cid] is not None] without_value = [cid for cid in selected if values[cid] is None] with_value.sort(key=lambda cid: (values[cid], cid), reverse=(sort_dir == 'desc')) # مقادیر خالی همیشه آخر می‌آیند (مستقل از جهت مرتب‌سازی) تا فهرست # با ستون‌های «قرار دستی/بازدید/هماهنگی» بی‌معنا نشود. ordered_ids = with_value + without_value else: ordered_ids = [] customers_objj = customers_objj.filter(id__in=ordered_ids) if ordered_ids: ordering = Case( *[When(id=cid, then=Value(index)) for index, cid in enumerate(ordered_ids)], output_field=models.IntegerField(), ) customers_objj = customers_objj.order_by(ordering) else: customers_objj = customers_objj.order_by('id') counts = { 'total': len(stale_ids), 'uncoordinated': len(selected_map['uncoordinated']), # عددِ هر کارت دقیقاً همان چیزی است که با کلیک روی آن دیده می‌شود. # یک مشتری می‌تواند چند منبع داشته باشد، پس جمع سه منبع می‌تواند از # «تعیین تکلیف شده» بیشتر شود؛ جزئیات در دراپ‌داون «نوع قرار» است. 'manual': len(selected_map['manual']), 'scheduled': len(selected_map['scheduled']), 'coordination': len(selected_map['coordination']), 'coordinated': len(selected_map['coordinated']), # کارت «نیازمند پیگیری امروز» — همیشه عدد امروز را نشان می‌دهد، # مستقل از تاریخی که کاربر در تقویم انتخاب کرده است. 'followup_today': today_followup_count, # پیگیری‌های موعدگذشته که هنوز تعیین تکلیف نشده‌اند (هشدار قرمز). 'followup_overdue': overdue_followup_count, 'selected': len(selected), 'view_total': len(selected_map[view]), 'buckets': buckets, # برای متن راهنمای بالای صفحه («بیش از N ماه از بازدید گذشته»). 'period_months': period_months, } try: entezar_bazdid = CacheTable.objects.get(key='9mahbazdid') # کارت داشبورد = «کارِ فوری» = تعیین تکلیف نشده. entezar_bazdid.value = counts['uncoordinated'] entezar_bazdid.save() except CacheTable.DoesNotExist: pass paginator = Paginator(customers_objj, ENTEZAR_PAGE_SIZE) page_number = request.GET.get('page', None) customerHa = paginator.get_page(page_number) # لینک صفحه‌بندی با حفظ همهٔ فیلترهای فعلی. def _page_link(number): params = request.GET.copy() params['page'] = number return '?' + params.urlencode() page_prev = _page_link(customerHa.previous_page_number()) if customerHa.has_previous() else '' page_next = _page_link(customerHa.next_page_number()) if customerHa.has_next() else '' # ---------- لینک‌های حفظ‌کنندهٔ فیلترها ---------- def _qs(**overrides): """پارامترهای فعلی + تغییرهای داده‌شده (بدون page). None ⇒ حذف.""" params = request.GET.copy() params.pop('page', None) for key, value in overrides.items(): if value is None: params.pop(key, None) else: params[key] = value query = params.urlencode() return '?' + query if query else '?' view_links = {name: _qs(view=name) for name in ENTEZAR_VIEWS} # کارت «نیازمند پیگیری امروز» همیشه به امروز می‌رود (تاریخ انتخابی پاک می‌شود). view_links[ENTEZAR_FOLLOWUP_VIEW] = _qs(view=ENTEZAR_FOLLOWUP_VIEW, fdate=None) # ---------- ناوبری تقویم در نمای پیگیری ---------- def _followup_link(target_date): return _qs(view=ENTEZAR_FOLLOWUP_VIEW, fdate=JalaliDate(target_date).strftime('%Y/%m/%d')) followup_nav = { 'prev': _followup_link(followup_date - datetime.timedelta(days=1)), 'next': _followup_link(followup_date + datetime.timedelta(days=1)), 'today': _followup_link(today_date), 'is_today': followup_date == today_date, } # لینک هشدار «عقب‌افتاده‌ها» و لینک بازگشت از آن حالت. followup_overdue_link = _qs(view=ENTEZAR_FOLLOWUP_VIEW, fdate=None, missed=1) followup_ontime_link = _qs(view=ENTEZAR_FOLLOWUP_VIEW, fdate=None, missed=None) delay_links = {'ez_all': _qs(delay=None)} delay_links.update({name: _qs(delay=name) for name in ENTEZAR_DELAYS}) sort_links = {} for column, default_dir in ENTEZAR_SORT_COLUMNS.items(): # کلیک دوباره روی همان ستون، جهت را برمی‌گرداند. next_dir = ('desc' if sort_dir == 'asc' else 'asc') \ if sort_field == column else default_dir sort_links[column] = _qs(sort=column, dir=next_dir) # سرستون‌های مرتب‌شدنی (به همان ترتیب ستون‌های جدول). # در نمای «نیازمند پیگیری امروز» ستون «تأخیر» نمایش داده **نمی‌شود**: # این فهرست دربارهٔ «موعد امروز» است، نه عقب‌افتادگی بازدید. columns = [ ('name', 'نام و نام خانوادگی', 21), ('status', 'وضعیت قرار', 12), ('manual', 'قرار دستی', 10), ('scheduled', 'بازدید برنامه‌ریزی‌شده', 10), ('coordination', 'هماهنگی', 10), ('last_visit', 'آخرین بازدید', 9), ('overdue', 'تأخیر', 8), ('phone', 'تلفن', 9), ('address', 'آدرس', 11), ] if is_followup_view: hidden_width = next(width for key, _, width in columns if key == 'overdue') columns = [col for col in columns if col[0] != 'overdue'] # عرض ستون حذف‌شده به نام اضافه می‌شود تا مجموع همچنان ۱۰۰٪ بماند. columns[0] = (columns[0][0], columns[0][1], columns[0][2] + hidden_width) table_columns = [{'key': key, 'label': label, 'width': width} for key, label, width in columns] hidden_columns = ['overdue'] if is_followup_view else [] sort_header = [ { 'key': column, 'label': label, 'link': sort_links[column], 'active': sort_field == column, 'dir': sort_dir if sort_field == column else '', } for column, label in ((key, label) for key, label, _ in columns) ] # لینک «حذف همهٔ فیلترها»: نمای پیش‌فرض (امروز)، بدون تأخیر/جست‌وجو/مرتب‌سازی. clear_link = '?view=' + ENTEZAR_DEFAULT_VIEW has_filters = bool( delay or searchVal or sort_field != 'last_visit' or sort_dir != 'asc' # تاریخِ انتخاب‌شده یا حالت «فقط عقب‌افتاده‌ها» هم فیلتر است. or (is_followup_view and followup_date != today_date) or show_missed ) # اطلاعات هر ردیف برای قالب: قرارها + مدت تأخیر + آخرین بازدید مؤثر. appointment_rows = {} for customer in customerHa.object_list: entry = dict(sources.get(customer.id) or {}) # در نمای پیگیری، تاریخ همان روز ملاک نمایش است (حتی اگر گذشته باشد و # در ``appointment_sources_map`` که فقط آینده را می‌بیند، نباشد). followup_entry = followups.get(customer.id) or {} entry.update(followup_entry) flags = [key for key in (SOURCE_MANUAL, SOURCE_SCHEDULED, SOURCE_COORDINATION) if key in entry] appointment_rows[customer.id] = { 'manual': entry.get(SOURCE_MANUAL), 'scheduled': entry.get(SOURCE_SCHEDULED), 'coordination': entry.get(SOURCE_COORDINATION), 'overdue_days': overdue_days.get(customer.id, 0), 'last_visit': visits.get(customer.id), # تعداد منابع (۰ تا ۳) — قالب بر اساس آن برچسب وضعیت را می‌سازد. 'source_count': len(flags), # تاریخ پیگیری این نمای خاص (برای ستون اختصاصی نمای «امروز»). 'followup_at': (min(followup_entry.values()) if followup_entry else None), } return render(request, 'admin_panel/entezar.html', { 'customers': customerHa, 'searchStatus': searchStatus, 'full_path': full_path, 'searchVal': searchVal, 'searchOn': searchOn, 'view': view, 'counts': counts, 'appointment_rows': appointment_rows, 'view_links': view_links, 'delay_links': delay_links, 'sort_links': sort_links, 'sort_header': sort_header, 'clear_link': clear_link, 'has_filters': has_filters, 'delay': delay, 'delays': ENTEZAR_DELAYS, 'sort': sort_field, 'sort_dir': sort_dir, 'sort_columns': ENTEZAR_SORT_COLUMNS, 'table_columns': table_columns, 'hidden_columns': hidden_columns, 'column_count': len(table_columns), 'page_prev': page_prev, 'page_next': page_next, 'views': ENTEZAR_VIEWS, # نمای «نیازمند پیگیری» 'is_followup_view': is_followup_view, 'followup_view': ENTEZAR_FOLLOWUP_VIEW, 'followup_date': followup_date, 'followup_date_jalali': followup_date_jalali, 'followup_nav': followup_nav, 'show_missed': show_missed, 'followup_overdue_link': followup_overdue_link, 'followup_ontime_link': followup_ontime_link, }) @login_required(login_url='/panel/p_login/') @permission_required('admin_panel.DelCustomer', raise_exception=True) def customerDel(request, c_id): customer_obj = get_object_or_404(Customer, id=c_id) try: # ۱. بررسی وجود هرگونه سابقه مالی/حسابداری (حتی با مانده صفر) has_articles = Article.objects.filter(customer=customer_obj).exists() # ۲. بررسی وجود هرگونه سابقه چک یا نسیه (حتی در صورت پاس شدن و تسویه کامل) has_sarresids = Sarresid.objects.filter(customer=customer_obj).exists() # ۳. بررسی وجود هرگونه فاکتور ثبت شده برای این مشتری has_factors = Factor.objects.filter(customer=customer_obj).exists() # ۴. بررسی وجود هرگونه سابقه اقدام، بازدید یا نظرسنجی has_actions = Action.objects.filter(customer=customer_obj).exists() # ۵. اگر هر کدام از این سوابق وجود داشته باشد، به هیچ عنوان اجازه غیرفعال‌سازی/حذف نمی‌دهیم if has_articles or has_sarresids or has_factors or has_actions: error_msg = f"عملیات ناموفق: مشتری '{customer_obj.name_family}' به دلیل داشتن سابقه در سیستم قابل حذف نیست! دلایل: " reasons = [] if has_articles: reasons.append("داشتن تراکنش مالی یا گردش حسابداری") if has_sarresids: reasons.append("داشتن سابقه چک یا نسیه (حتی تسویه شده)") if has_factors: reasons.append("داشتن فاکتور ثبت‌شده") if has_actions: reasons.append("داشتن سابقه اقدامات، بازدید یا نظرسنجی") error_msg += "، ".join(reasons) + "." # ارسال پیام خطا به تمپلیت و بازگرداندن کاربر به صفحه قبلی messages.error(request, error_msg) return redirect(request.META.get('HTTP_REFERER', '/panel/customers/')) # ۶. در صورتی که مشتری کاملاً خام بود و هیچ سابقه‌ای در هیچ بخشی نداشت، حذف می‌شود customer_obj.delete() messages.success(request, f"مشتری '{customer_obj.name_family}' که فاقد هرگونه سابقه‌ای بود، با موفقیت از سیستم حذف شد.") return redirect('/panel/customers/') except Exception as e: logger.error(f"Error in deleting customer {c_id}: {str(e)}", exc_info=True) messages.error(request, "خطای سیستمی در بررسی سوابق و حذف مشتری رخ داد.") return redirect(request.META.get('HTTP_REFERER', '/panel/customers/')) @login_required(login_url='/panel/p_login/') def customerHesabdari(request ,c_id): customer = Customer.objects.get(id=c_id) # مانده حساب از همان تابع مشترکی می‌آید که قالب‌های پیامک استفاده می‌کنند تا # عددِ این صفحه و پیامک هرگز واگرا نشوند (منبع حقیقت: sms/balance.py). mandehesabCs = customer_balance(customer) try: articls = Article.objects.filter(customer=customer).order_by('-createdDT') except: articls = None dateNow = datetime.datetime.now().date data = {'customer': customer, 'mandehesabCs': mandehesabCs, 'articls': articls} return render(request, 'admin_panel/customerHesabDari.html', data) @login_required(login_url='/panel/p_login/') def sarresidh(request ,c_id): customer = Customer.objects.get(id=c_id) try: all_sarresid = Sarresid.objects.filter(customer=customer).order_by('-sarresidDT') except: all_sarresid = None data = {'customer': customer, 'all_sarresid': all_sarresid} return render(request, 'admin_panel/customerSarresid.html', data) @login_required(login_url='/panel/p_login/') def customerHesabdariAdd(request ,c_id): user_op = request.user price = request.POST.get('price', None) type = request.POST.get('type', None) typePy = request.POST.get('typePy', None) desc = request.POST.get('desc', None) nahvePardakhat = request.POST.get('nahvePardakhat', None) newArticl = Article() newArticl.customer = Customer.objects.get(id=c_id) newArticl.desc = desc newArticl.typePy = typePy if type == 'credit': newArticl.credit = price newArticl.debit = 0 elif type == 'debit': newArticl.debit = price newArticl.credit = 0 newArticl.user = user_op newArticl.nahvePardakht = nahvePardakhat newArticl.save() # سند بستانکار (credit>0 و debit=0) یعنی مشتری پول پرداخت کرده ⇒ پیامک تشکر. if type == 'credit': notify_payment_thanks(newArticl.customer, newArticl.credit, article_id=newArticl.id, method=nahvePardakhat, created_by=user_op) return redirect('/panel/customer/'+str(c_id)+'/hesabdari')