improve bazdid
This commit is contained in:
1 parent
2aee85593f
commit
534a84f977
12 files changed
+2305
-339
No files matched your search
@@ -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
@@ -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/')
|
||||
|
||||
Reference in new issue
Block a user