improve bazdid 3

This commit is contained in:
javad committed 2026-10-21 12:24:52 +03:30
1 parent f8d768a724
commit f86d060e4d
6 files changed
+337 -121

No files matched your search

@@ -62,7 +62,9 @@
{% endif %}
{% for action in actions %}
<div customer_id='{{ action.customer.id }}' action_id='{{ action.id }}' class="columns is-mobile is-multiline is-gapless actionBox {{ bgBox }}">
<div customer_id='{{ action.customer.id }}' action_id='{{ action.id }}'
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">
{% ifequal action.status 'finish' %}
@@ -113,7 +115,9 @@
<p style="text-align: center; margin-bottom: 20px; color: #d90000; font-size: 18px;"> کارهای مانده از روزهای قبل</p>
{% for action in actionPr %}
<div customer_id='{{ action.customer.id }}' action_id='{{ action.id }}' class="columns is-mobile is-multiline is-gapless actionBox {{ bgBox }}">
<div customer_id='{{ action.customer.id }}' action_id='{{ action.id }}'
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">
{% ifequal action.status 'finish' %}
@@ -474,6 +478,26 @@
// مقدار undefined می‌شود. بدون این محافظ، یک ناهم‌خوانی نام کلاس باعث
// TypeError می‌شد و بقیهٔ منطقِ هندلر (پنهان‌کردن فیلدها، برداشتن تیک خودکار)
// اجرا نمی‌شد.
// پرکردن پیکر «تا تاریخ پیگیری نشود» از مقدار ذخیره‌شدهٔ مشتری.
// بدون این، هنگام بازکردن مجدد یک اقدام، فیلد همیشه خالی دیده می‌شد
// (مقدار در دیتابیس بود ولی مودال آن را بارگذاری نمی‌کرد).
function seedHoldPicker(isoValue) {
var box = $("#visitHoldUntil");
if (!isoValue) {
resetHoldPicker();
return;
}
var ms = new Date(isoValue).getTime();
if (isNaN(ms)) {
resetHoldPicker();
return;
}
if (dateTimePickerHoldUntil && typeof dateTimePickerHoldUntil.setDate === 'function') {
dateTimePickerHoldUntil.setDate(ms);
}
box.val(String(ms));
}
function resetHoldPicker() {
if (dateTimePickerHoldUntil && typeof dateTimePickerHoldUntil.setDate === 'function') {
dateTimePickerHoldUntil.setDate();
@@ -576,8 +600,10 @@
isEditMode = true;
$("#sabtBtn05").val("ویرایش");
var actionEditUrl = "{% url 'admin_panel:customerEditAction' 1111 2222 %}";
var action_id = $(this).parent().attr("action_id");
var customer_id = $(this).parent().attr("customer_id");
var row = $(this).parent();
var action_id = row.attr("action_id");
var customer_id = row.attr("customer_id");
var customerHold = row.attr("customer_hold") || "";
actionEditUrl = actionEditUrl.replace('1111', customer_id);
actionEditUrl = actionEditUrl.replace('2222', action_id);
$("#ActionBox").prop("action", actionEditUrl);
@@ -602,6 +628,9 @@
// فیلد «تا تاریخ پیگیری نشود» با نوع اقدام هم‌راستا شود (در حالت ویرایش هم).
closeHoldHelp();
toggleCoordinationField(ActionParseData[0]['fields']['actionType']);
// مقدار ذخیره‌شدهٔ «قرار بعدی» را در همان فیلد نشان بده (بعد از toggle،
// چون toggle برای انواع غیرهماهنگی فیلد را پاک می‌کند).
seedHoldPicker(customerHold);
$("#desc").val(ActionParseData[0]['fields']['desc']);
$("#userID").val(ActionParseData[0]['fields']['forUser']);
if(ActionParseData[0]['fields']['status'] == 'finish'){
+35 -20
View File
@@ -24,7 +24,7 @@
-webkit-overflow-scrolling: touch;
}
@media (min-width: 1024px) {
.ez-stats { grid-auto-flow: row; grid-template-columns: repeat(5, 1fr); overflow: visible; }
.ez-stats { grid-auto-flow: row; grid-template-columns: repeat(6, 1fr); overflow: visible; }
}
.ez-stat {
display: flex; flex-direction: column; gap: .18rem;
@@ -40,6 +40,7 @@
.ez-stat.success { border-top-color: #16a34a; }
.ez-stat.warning { border-top-color: #d97706; }
.ez-stat.link { border-top-color: #0284c7; }
.ez-stat.violet { border-top-color: #7c3aed; }
.ez-stat-top {
display: flex; align-items: baseline; justify-content: space-between; gap: .4rem;
@@ -128,6 +129,7 @@
.ez-badge.success { background: #dcfce7; color: #15803d; }
.ez-badge.warning { background: #fef3c7; color: #b45309; }
.ez-badge.mixed { background: #e0f2fe; color: #0369a1; }
.ez-badge.coordination { background: #ede9fe; color: #6d28d9; }
/* تأخیر به‌صورت چیپ (خوانا در یک نگاه) */
.ez-late {
@@ -202,10 +204,8 @@
<b>بیش از {{ counts.period_months }} ماه</b> گذشته باشد.
روی هر کارت بزنید تا فقط همان گروه نمایش داده شود؛
<b>کل = بدون قرار + با قرار</b>.
{% if counts.both %}
({{ counts.both }} مشتری هم قرار هماهنگی دارند و هم بازدید
برنامه‌ریزی‌شده، پس در هر دو کارت شمرده می‌شوند.)
{% endif %}
سه کارتِ آخر سه نوع قرارِ جدا هستند؛ یک مشتری می‌تواند بیش از
یکی داشته باشد، پس جمع آن‌ها ممکن است از «با قرار» بیشتر شود.
</p>
</div>
@@ -259,6 +259,16 @@
</span>
<span class="ez-desc">اقدام «بازدید» با تاریخ آینده ثبت شده است</span>
</a>
<a href="?view=coordination&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="ez-stat violet {% if view == 'coordination' %}is-active{% endif %}"
title="اقدام «هماهنگی» با تاریخ پیگیری در آینده">
<span class="ez-stat-top">
<span class="ez-lbl">هماهنگی</span>
<span class="ez-num" style="color:#7c3aed">{{ counts.coordination }}</span>
</span>
<span class="ez-desc">تماس گرفته شده و پیگیری به آینده موکول شده است</span>
</a>
</div>
{# تفکیک مدت تأخیر در نمای جاری #}
@@ -293,21 +303,23 @@
<div class="table-container">
<table class="table ez-table">
<colgroup>
<col style="width: 23%">
<col style="width: 13%">
<col style="width: 21%">
<col style="width: 12%">
<col style="width: 10%">
<col style="width: 10%">
<col style="width: 11%">
<col style="width: 10%">
<col style="width: 9%">
<col style="width: 8%">
<col style="width: 9%">
<col style="width: 11%">
<col style="width: 13%">
</colgroup>
<thead>
<tr>
<th>نام و نام خانوادگی</th>
<th>وضعیت قرار</th>
<th>قرار هماهنگی</th>
<th>قرار دستی</th>
<th>بازدید برنامه‌ریزی‌شده</th>
<th>هماهنگی</th>
<th>آخرین بازدید</th>
<th>تأخیر</th>
<th>تلفن</th>
@@ -328,23 +340,26 @@
</td>
{% with appt=appointment_rows|row_for:customer.id %}
<td data-label="وضعیت" class="ez-nolabel">
{% if appt.manual and appt.scheduled or appt.manual and appt.coordination %}
<span class="ez-badge mixed">قرار + بازدید</span>
{% if appt.source_count > 1 %}
<span class="ez-badge mixed">{{ appt.source_count }} قرار</span>
{% elif appt.manual %}
<span class="ez-badge warning">قرار هماهنگی</span>
{% elif appt.scheduled or appt.coordination %}
<span class="ez-badge success">بازدید برنامه‌ریزی‌شده</span>
<span class="ez-badge warning">قرار دستی</span>
{% elif appt.scheduled %}
<span class="ez-badge success">بازدید برنامه</span>
{% elif appt.coordination %}
<span class="ez-badge coordination">هماهنگی</span>
{% else %}
<span class="ez-badge danger">بدون قرار</span>
{% endif %}
</td>
<td data-label="قرار هماهنگی" class="ez-num ez-nowrap">
<td data-label="قرار دستی" class="ez-num ez-nowrap">
{% if appt.manual %}{{ appt.manual|toJalali }}{% else %}<span class="ez-muted">—</span>{% endif %}
</td>
<td data-label="بازدید برنامه" class="ez-num ez-nowrap">
{% if appt.scheduled %}{{ appt.scheduled|toJalali }}
{% elif appt.coordination %}{{ appt.coordination|toJalali }}<div class="ez-sub">در انتظار تماس</div>
{% else %}<span class="ez-muted">—</span>{% endif %}
{% if appt.scheduled %}{{ appt.scheduled|toJalali }}{% else %}<span class="ez-muted">—</span>{% endif %}
</td>
<td data-label="هماهنگی" class="ez-num ez-nowrap">
{% if appt.coordination %}{{ appt.coordination|toJalali }}{% else %}<span class="ez-muted">—</span>{% endif %}
</td>
<td data-label="آخرین بازدید" class="ez-num ez-nowrap lastView">
{% if appt.last_visit %}{{ appt.last_visit|toJalali }}{% else %}<span class="ez-muted">بدون بازدید</span>{% endif %}
@@ -382,7 +397,7 @@
</tr>
{% empty %}
<tr>
<td colspan="8" class="has-text-centered py-5">
<td colspan="9" class="has-text-centered py-5">
<span class="has-text-grey">موردی در این نما پیدا نشد.</span>
</td>
</tr>
+19 -13
View File
@@ -91,21 +91,26 @@ def _action_payload_from_request(request):
}
def _apply_visit_hold(customer_obj, payload, *, status):
def _apply_visit_hold(customer_obj, payload, *, status, is_new):
"""هم‌راستاکردن «قرار بعدی» با اقدام ثبت/ویرایش‌شده.
قواعد (یکسان برای ایجاد و ویرایش — قبلاً ایجاد این را انجام نمی‌داد):
قواعد:
* فقط برای اقدام نوع «هماهنگی» معنا دارد؛
- اگر تاریخ قرار داده شده باشد ⇒ ``set_visit_hold`` (فقط رو به جلو)
- اگر «انجام شد» تیک خورده و تاریخ خالی است ⇒ پیش‌فرض ۱۴ روز
- اگر هماهنگی لغو شده باشد ⇒ قرار پاک می‌شود
* **هر اقدام دیگری** (از جمله «بازدید») ⇒ قرار دستی پاک می‌شود؛ چون هدفِ
قرار (پیگیری در آینده) با انجام/برنامه‌ریزی بازدید منتفی است.
* برای اقدام نوع «هماهنگی»:
- تاریخ قرار داده شده ⇒ ``set_visit_hold`` (فقط رو به جلو)
- «انجام شد» تیک خورده و تاریخ خالی ⇒ پیش‌فرض ۱۴ روز
- لغو شده ⇒ (در ایجاد) پاک می‌شود
* برای **هر اقدام دیگری** (از جمله «بازدید»):
- در حالت **ایجاد**، این اقدام آخرین فعالیت مشتری است ⇒ قرار دستی پاک
می‌شود (سناریوی گزارش‌شده: ثبت بازدید پس از هماهنگی).
- در حالت **ویرایش**، رکوردی تاریخی است و ممکن است قرارِ فعلی از یک
هماهنگیِ *جدیدتر* آمده باشد؛ پس دست نمی‌زنیم. اعتبارسنجی
:func:`manual_holds_map` (شرط «آخرین فعالیت = هماهنگی») خودش تصمیم
می‌گیرد. بدون این تفکیک، ویرایش یک اقدام قدیمی قرار معتبر را پاک می‌کرد.
"""
is_coordination = payload['actionType'] == Action.ActionType.HAMAHANGI
if not is_coordination or status == Action.Status.CANCELL:
return clear_visit_hold(customer_obj)
return clear_visit_hold(customer_obj) if is_new else True
raw = payload.get('holdRaw')
if raw is not None:
@@ -152,7 +157,7 @@ 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, status=newAction.status)
_apply_visit_hold(customer_obj, payload, status=newAction.status, is_new=True)
# پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
if newAction.actionType == Action.ActionType.BAZDID:
@@ -210,9 +215,10 @@ 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)
# «قرار بعدی»: با ویرایش هم‌راستا می‌شود؛ اگر اقدام دیگر هماهنگی نباشد یا
# هماهنگی لغو شود، قرار دستی پاک می‌شود.
_apply_visit_hold(customer_obj, payload, status=newAction.status)
# «قرار بعدی»: با ویرایش هم‌راستا می‌شود. برای اقدام غیرهماهنگی عمداً
# پاک نمی‌کنیم؛ رکورد ویرایش‌شده ممکن است قدیمی‌تر از یک هماهنگیِ جدیدتر
# باشد و پاک‌کردن، قرار معتبر را از بین ببرد.
_apply_visit_hold(customer_obj, payload, status=newAction.status, is_new=False)
#return redirect('/panel/customer/'+str(c_id)+'/action')
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
+47 -47
View File
@@ -24,7 +24,8 @@ from ..visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED
# نماهای صفحهٔ «انتظار» (فیلتر ?view=). پیش‌فرض = فقط «بدون قرار» تا رفتار
# قبلی صفحه برای کاربران حفظ شود.
ENTEZAR_VIEWS = ('uncoordinated', 'manual', 'scheduled', 'coordinated', 'all')
ENTEZAR_VIEWS = ('uncoordinated', 'manual', 'scheduled', 'coordination',
'coordinated', 'all')
ENTEZAR_DEFAULT_VIEW = 'uncoordinated'
# حالت‌های مرتب‌سازی (?sort=):
# oldest ⇒ قدیمی‌ترین بازدید اول (پیش‌فرض/اولویت کارشناس)
@@ -341,18 +342,17 @@ def customerEdit(request, c_id):
@login_required(login_url='/panel/p_login/')
def entezar(request):
"""فهرست «منتظر بازدید» + تفکیک «با قرار» و «بدون قرار».
"""فهرست «منتظر بازدید» + تفکیک بر اساس **نوع قرار**.
منبع حقیقت تاریخ بازدید ``visit_policy.last_visit_map`` است و منبع «قرار بعدی»
``appointment_sources_map`` (سه منبع: قرار دستی، بازدید برنامه‌ریزی‌شده،
هماهنگیِ در انتظار). هر مشتری دقیقاً در یکی از چهار گروه زیر است:
منبع حقیقت تاریخ بازدید ``visit_policy.last_visit_map`` است و منبع قرارها
``appointment_sources_map`` که سه منبع مستقل دارد:
* ``A`` — معوق بدون هیچ قرار (پیش‌فرض نمایش؛ رفتار قبلی صفحه)
* ``B`` — معوق با قرار دستی (تعویق از فرم هماهنگی)
* ``C`` — معوق با بازدید برنامه‌ریزی‌شده یا هماهنگی در انتظار
* ``D`` — معوق با بیش از یک منبعِ قرار
* ``manual`` — قرار دستی روی مشتری (تعویق از فرم هماهنگی)
* ``scheduled`` — اقدام «بازدید» با تاریخ آینده
* ``coordination`` — اقدام «هماهنگی» با تاریخ پیگیری در آینده
فیلتر با ``?view=`` و قابل ترکیب با جست‌وجوی آدرس است.
هر فیلتر فقط منبع خودش را نشان می‌دهد (یک مشتری می‌تواند چند منبع داشته
باشد و در چند فیلتر دیده شود). فیلتر با ``?view=`` و قابل ترکیب با جست‌وجو.
"""
searchVal = request.GET.get('searchVal', '')
searchOn = request.GET.get('searchOn', '')
@@ -403,29 +403,35 @@ def entezar(request):
if visits.get(customer_id) is None or visits[customer_id] <= due_cut
]
# تفکیک چهارگروهی (MECE): هر مشتری فقط در یک گروه.
groups = {'A': [], 'B': [], 'C': [], 'D': []}
# تفکیک بر اساس **منبعِ** قرار: هر مشتری می‌تواند صفر تا سه منبع داشته باشد
# (قرار دستی، بازدید برنامه‌ریزی‌شده، هماهنگی). قبلاً «هماهنگی» با «بازدید»
# در یک گروه جمع می‌شد و در نمای «بازدید برنامه‌ریزی‌شده» دیده می‌شد؛ حالا
# هر منبع گروه نمایشیِ خودش را دارد.
with_manual, with_scheduled, with_coordination = set(), set(), set()
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)
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)
selected = {
'uncoordinated': groups['A'],
'manual': groups['B'] + groups['D'],
'scheduled': groups['C'] + groups['D'],
'coordinated': groups['B'] + groups['C'] + groups['D'],
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,
}[view]
}
selected = selected_map[view]
# «مدت تأخیر» = از چه زمانی بازدید عقب افتاده است (به روز).
# مبنای محاسبه «موعد بازدید» است، نه خود بازدید: موعد = بازدید + دوره.
@@ -491,17 +497,14 @@ def entezar(request):
counts = {
'total': len(stale_ids),
'uncoordinated': len(groups['A']),
# توجه: عددِ هر کارت باید **همان چیزی** باشد که با کلیک روی آن کارت
# دیده می‌شود. فیلتر ``view=manual`` گروه‌های B و D را نشان می‌دهد و
# ``view=scheduled`` گروه‌های C و D را؛ پس اینجا هم همان اجتماع
# شمرده می‌شود (قبلاً فقط B/C شمرده می‌شد و عدد با فهرست نمی‌خواند).
'manual': len(groups['B']) + len(groups['D']),
'scheduled': len(groups['C']) + len(groups['D']),
'manual_only': len(groups['B']),
'scheduled_only': len(groups['C']),
'both': len(groups['D']),
'coordinated': len(groups['B']) + len(groups['C']) + len(groups['D']),
'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']),
'selected': len(selected),
'buckets': buckets,
# برای متن راهنمای بالای صفحه («بیش از N ماه از بازدید گذشته»).
@@ -524,19 +527,16 @@ def entezar(request):
appointment_rows = {}
for customer in customerHa.object_list:
entry = sources.get(customer.id) or {}
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),
'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'
),
# تعداد منابع (۰ تا ۳) — قالب بر اساس آن برچسب وضعیت را می‌سازد.
'source_count': len(flags),
}
return render(request, 'admin_panel/entezar.html', {