improve bazdid

This commit is contained in:
javad committed 2026-10-21 10:50:42 +03:30
1 parent 2aee85593f
commit 534a84f977
12 files changed
+2305 -339

No files matched your search

+39 -2
View File
@@ -12,8 +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, normalize_finish_datetime,
refresh_last_bazdid, validate_action_payload)
as_aware, clear_visit_hold, default_hold_until,
normalize_finish_datetime, normalize_hold_until,
refresh_last_bazdid, set_visit_hold,
validate_action_payload)
@login_required(login_url='/panel/p_login/')
@@ -80,6 +82,8 @@ def _action_payload_from_request(request):
'status': Action.Status.FINISH if is_done else Action.Status.WAITE,
'actionDateTime': _parse_form_datetime(request.POST.get('dateTimeAnjam')),
'finishDateTime': _parse_form_datetime(request.POST.get('FinishDateTime')),
# فقط برای «هماهنگی» معنا دارد؛ تاریخِ «تا کِی پیگیری نشود».
'holdRaw': _parse_form_datetime(request.POST.get('visitHoldUntil')),
'desc': request.POST.get('desc', None),
'result': request.POST.get('result', None),
'userID': request.POST.get('userID', None),
@@ -87,6 +91,26 @@ def _action_payload_from_request(request):
}
def _apply_visit_hold(customer_obj, payload):
"""نوشتن/پاک‌کردن «قرار بعدی» بر اساس اقدام ثبت/ویرایش‌شده.
قواعد:
* فقط برای اقدام نوع «هماهنگی» معنا دارد.
* اگر تاریخ قرار داده شده باشد ⇒ ``set_visit_hold`` (فقط رو به جلو).
* اگر هماهنگی «انجام شد» تیک خورده و تاریخ قرار خالی است ⇒ پیش‌فرض ۱۴ روز.
* اگر هماهنگی لغو شد (``cancell``) یا نوع عوض شد ⇒ قرار پاک می‌شود.
"""
if payload['actionType'] != Action.ActionType.HAMAHANGI:
return
raw = payload.get('holdRaw')
if raw is not None:
target = normalize_hold_until(raw)
set_visit_hold(customer_obj, target)
return
if payload['status'] == Action.Status.FINISH:
set_visit_hold(customer_obj, default_hold_until())
def _reject(request, error, redirect_to):
"""پیام فارسی خطا به کاربر و بازگشت بدون ذخیرهٔ چیزی."""
messages.error(request, '؛ '.join(error.messages))
@@ -98,6 +122,9 @@ def customerNewAction(request ,c_id):
payload = _action_payload_from_request(request)
try:
validate_action_payload(payload)
# اعتبارسنجی «قرار بعدی» پیش از هر نوشتنی (سقف ۶ ماه و تاریخ گذشته).
if payload['actionType'] == Action.ActionType.HAMAHANGI and payload['holdRaw']:
normalize_hold_until(payload['holdRaw'])
except ValidationError as error:
return _reject(request, error, '/panel/customer/' + str(c_id) + '/action')
@@ -119,6 +146,8 @@ def customerNewAction(request ,c_id):
# last_bazdid_dt فقط از تابع واحد بازمحاسبه می‌شود؛ نه از finishDateTime.
refresh_last_bazdid(customer_obj, reason=REASON_CREATE, user=request.user)
# «قرار بعدی» فقط برای هماهنگی.
_apply_visit_hold(customer_obj, payload)
# پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
if newAction.actionType == Action.ActionType.BAZDID:
@@ -147,6 +176,8 @@ def customerEditAction(request ,c_id, action_id):
payload = _action_payload_from_request(request)
try:
validate_action_payload(payload)
if payload['actionType'] == Action.ActionType.HAMAHANGI and payload['holdRaw']:
normalize_hold_until(payload['holdRaw'])
except ValidationError as error:
return _reject(
request, error,
@@ -174,6 +205,12 @@ def customerEditAction(request ,c_id, action_id):
# (تا مقداری که این رکورد ساخته بود با منبع حقیقت هم‌راستا شود).
reason = REASON_CANCEL if (was_done and not payload['is_done']) else REASON_EDIT
refresh_last_bazdid(customer_obj, reason=reason, user=request.user)
# «قرار بعدی»: با ویرایش هم‌راستا می‌شود؛ اگر اقدام دیگر هماهنگی نباشد یا
# هماهنگی لغو شود، قرار دستی پاک می‌شود.
if payload['actionType'] == Action.ActionType.HAMAHANGI and newAction.status != Action.Status.CANCELL:
_apply_visit_hold(customer_obj, payload)
else:
clear_visit_hold(customer_obj)
#return redirect('/panel/customer/'+str(c_id)+'/action')
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
+173 -20
View File
@@ -2,7 +2,7 @@ import datetime
import os
from django.db import models
from django.db.models import Sum, Q
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
@@ -18,7 +18,19 @@ 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 (last_visit_map, next_appointment_map, visit_due_at)
from ..visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED,
appointment_sources_map, last_visit_map, visit_due_at,
visit_due_date, visit_period_months)
# نماهای صفحهٔ «انتظار» (فیلتر ?view=). پیش‌فرض = فقط «بدون قرار» تا رفتار
# قبلی صفحه برای کاربران حفظ شود.
ENTEZAR_VIEWS = ('uncoordinated', 'manual', 'scheduled', 'coordinated', 'all')
ENTEZAR_DEFAULT_VIEW = 'uncoordinated'
# حالت‌های مرتب‌سازی (?sort=):
# oldest ⇒ قدیمی‌ترین بازدید اول (پیش‌فرض/اولویت کارشناس)
# newest ⇒ جدیدترین بازدید اول
# overdue ⇒ بیشترین تأخیر اول
ENTEZAR_SORTS = ('oldest', 'newest', 'overdue')
from ._helpers import logger, _parse_jalali_date, _parse_phone_rows
@@ -329,32 +341,59 @@ def customerEdit(request, c_id):
@login_required(login_url='/panel/p_login/')
def entezar(request):
"""فهرست «منتظر بازدید».
"""فهرست «منتظر بازدید» + تفکیک «با قرار» و «بدون قرار».
منبع حقیقت تاریخ بازدید، ``visit_policy.last_visit_map`` است (نه ستون کش‌شدهٔ
``last_bazdid_dt``) تا این صفحه و اسکنر پیامک هرگز واگرا نشوند. دورهٔ بازدید هم
از همان قالب پیامک خوانده می‌شود. مشتری دارای «تعویق فعال» (اقدام هماهنگی
در انتظار با تاریخ آینده) از فهرست خارج و به صف خدمات پس از فروش سپرده می‌شود.
منبع حقیقت تاریخ بازدید ``visit_policy.last_visit_map`` است و منبع «قرار بعدی»
``appointment_sources_map`` (سه منبع: قرار دستی، بازدید برنامه‌ریزی‌شده،
هماهنگیِ در انتظار). هر مشتری دقیقاً در یکی از چهار گروه زیر است:
* ``A`` — معوق بدون هیچ قرار (پیش‌فرض نمایش؛ رفتار قبلی صفحه)
* ``B`` — معوق با قرار دستی (تعویق از فرم هماهنگی)
* ``C`` — معوق با بازدید برنامه‌ریزی‌شده یا هماهنگی در انتظار
* ``D`` — معوق با بیش از یک منبعِ قرار
فیلتر با ``?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 # مقدار نامعتبر ⇒ پیش‌فرض، بدون خطا
sort = (request.GET.get('sort') or 'oldest').strip().lower()
if sort not in ENTEZAR_SORTS:
sort = '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) تا صفحه و اسکنر پیامک یک جواب بدهند.
due_cut_date = due_cut.astimezone(timezone.get_current_timezone()).date()
visits = last_visit_map(now=now)
appointments = next_appointment_map(now=now)
sources = appointment_sources_map(now=now)
customers_objj = Customer.objects.filter(
laghv_bazdid=False, name_family__isnull=False,
installDate__lte=due_cut_date,
)
if searchOn == 'address':
customers_objj = customers_objj.filter(address__contains=searchVal)
# جست‌وجو: نام، آدرس، تلفن (همه شماره‌ها) و کد شناسایی.
if searchVal:
if searchOn == 'address':
customers_objj = customers_objj.filter(address__contains=searchVal)
elif searchOn == 'name':
customers_objj = customers_objj.filter(name_family__contains=searchVal)
elif searchOn == 'phone':
customers_objj = customers_objj.filter(
Q(phone1__contains=searchVal) | Q(phone2__contains=searchVal)
| Q(phone3__contains=searchVal)
)
elif searchOn == 'code':
customers_objj = customers_objj.filter(idCode__contains=searchVal)
else:
searchStatus = False
else:
searchStatus = False
@@ -363,28 +402,142 @@ def entezar(request):
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
]
customers_objj = customers_objj.filter(id__in=stale_ids)
# «قرار بعدی» (هماهنگی توافق‌شده یا بازدید برنامه‌ریزی‌شده) یعنی این مشتری
# در این فهرست جایی ندارد تا آن تاریخ.
pending_ids = [cid for cid in stale_ids if cid in appointments]
if pending_ids:
customers_objj = customers_objj.exclude(id__in=pending_ids)
# تفکیک چهارگروهی (MECE): هر مشتری فقط در یک گروه.
groups = {'A': [], 'B': [], 'C': [], 'D': []}
for customer_id in stale_ids:
customer_sources = sources.get(customer_id) or {}
manual = SOURCE_MANUAL in customer_sources
scheduled = (SOURCE_SCHEDULED in customer_sources
or SOURCE_COORDINATION in customer_sources)
if manual and scheduled:
groups['D'].append(customer_id)
elif manual:
groups['B'].append(customer_id)
elif scheduled:
groups['C'].append(customer_id)
else:
groups['A'].append(customer_id)
customers_objj = customers_objj.order_by('last_bazdid_dt', 'id')
selected = {
'uncoordinated': groups['A'],
'manual': groups['B'] + groups['D'],
'scheduled': groups['C'] + groups['D'],
'coordinated': groups['B'] + groups['C'] + groups['D'],
'all': stale_ids,
}[view]
# «مدت تأخیر» = از چه زمانی بازدید عقب افتاده است (به روز).
# مبنای محاسبه «موعد بازدید» است، نه خود بازدید: موعد = بازدید + دوره.
# این‌طور مشتریِ تازه‌معوق هم در بازهٔ کوتاه قرار می‌گیرد و عدد معنا پیدا می‌کند.
today = now.astimezone(timezone.get_current_timezone()).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)
# مرتب‌سازی:
# oldest ⇒ قدیمی‌ترین بازدید اول (اولویت کارشناس)
# newest ⇒ جدیدترین بازدید اول
# overdue ⇒ بیشترین تأخیر اول (چه کسی بیشتر از همه عقب افتاده)
if sort == 'overdue':
ordered_ids = sorted(selected, key=lambda cid: (-overdue_days.get(cid, 0), cid))
customers_objj = customers_objj.filter(id__in=ordered_ids)
if ordered_ids:
# ترتیب در لایهٔ دیتابیس با CASE/WHEN (بدون بار حافظه‌ای).
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')
else:
# مرتب‌سازی بر مبنای **تاریخ مؤثر بازدید** (از Action)، نه ستون ممکن‌القدمت.
ordered_ids = sorted(
selected,
key=lambda cid: (
(visits.get(cid) or due_cut),
cid,
),
reverse=(sort == 'newest'),
)
customers_objj = customers_objj.filter(id__in=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)
# آمار «تأخیر» (روی کل گروه انتخابی، نه فقط صفحهٔ جاری).
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
counts = {
'total': len(stale_ids),
'uncoordinated': len(groups['A']),
'manual': len(groups['B']),
'scheduled': len(groups['C']),
'both': len(groups['D']),
'coordinated': len(groups['B']) + len(groups['C']) + len(groups['D']),
'selected': len(selected),
'buckets': buckets,
}
try:
entezar_bazdid = CacheTable.objects.get(key='9mahbazdid')
entezar_bazdid.value = len(stale_ids) - len(pending_ids)
# کارت داشبورد = «کارِ فوری» = بدون قرار.
entezar_bazdid.value = counts['uncoordinated']
entezar_bazdid.save()
except CacheTable.DoesNotExist:
pass
paginator = Paginator(customers_objj, 20) # Show 25 contacts per page.
paginator = Paginator(customers_objj, 20)
page_number = request.GET.get('page', None)
customerHa = paginator.get_page(page_number)
return render(request, 'admin_panel/entezar.html', {'customers': customerHa, 'searchStatus': searchStatus, 'full_path': full_path, 'searchVal': searchVal, 'searchOn':searchOn})
# اطلاعات هر ردیف برای قالب: قرارها + مدت تأخیر + آخرین بازدید مؤثر.
appointment_rows = {}
for customer in customerHa.object_list:
entry = sources.get(customer.id) or {}
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),
'group': (
'D' if (SOURCE_MANUAL in entry and (
SOURCE_SCHEDULED in entry or SOURCE_COORDINATION in entry))
else 'B' if SOURCE_MANUAL in entry
else 'C' if (SOURCE_SCHEDULED in entry or SOURCE_COORDINATION in entry)
else 'A'
),
}
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,
'sort': sort, 'sorts': ENTEZAR_SORTS,
'views': ENTEZAR_VIEWS,
})
@login_required(login_url='/panel/p_login/')