improve bazdid 4

This commit is contained in:
javad committed 2026-10-21 15:13:35 +03:30
1 parent f86d060e4d
commit 8fd881e0ea
10 files changed
+1752 -320

No files matched your search

+14 -5
View File
@@ -1,11 +1,11 @@
Project Root: khatibi
Generated At: 2026-10-20T17:07:57+00:00
Python Files: 187
Generated At: 2026-10-21T10:54:58+00:00
Python Files: 188
Django Files: 12
JavaScript/TypeScript Files: 14
HTML Files: 58
Total Files: 746
Total Directories: 78
Total Files: 754
Total Directories: 79
khatibi
├── .cursor
@@ -129,6 +129,7 @@ khatibi
│ │ ├── 0076_alter_smstemplate_text_help.py
│ │ ├── 0077_alter_smstemplate_text_balance_help.py
│ │ ├── 0078_action_visit_covering_index.py
│ │ ├── 0079_customer_visit_hold.py
│ │ └── __init__.py
│ ├── models
│ │ ├── __init__.py
@@ -323,10 +324,18 @@ khatibi
│ └── visit_policy.py
├── data9
│ ├── backup-20261020-180934.sqlite3
│ ├── backup-20261020-213406.sqlite3
│ ├── db2 (2).sqlite32
│ ├── db2.sqlite32
│ ├── db222.sqlite3
│ └── kt.sqlite3
│ ├── khatibi92669266_backups_db_backup_20261020_223011.sqlite3
│ ├── khatibi92669266_backups_db_backup_20261020_223011.sqlite3-shm
│ ├── khatibi92669266_backups_db_backup_20261020_223011.sqlite3-wal
│ ├── kt.sqlite3
│ ├── kt.sqlite3-shm
│ └── kt.sqlite3-wal
├── docs
│ └── visit_hold_entezar.md
├── graphify-out
│ ├── 2026-10-09
│ │ ├── .graphify_analysis.json
@@ -239,6 +239,8 @@
{% block bodyPage %}
{% endblock %}
{# بستن <div class=""> که بالاتر باز شده بود؛ پیش‌تر بسته نمی‌شد و HTML نامعتبر می‌شد. #}
</div>
</body>
<script>
@@ -164,6 +164,8 @@
{% block bodyPage %}
{% endblock %}
{# بستن <div class=""> که بالاتر باز شده بود (همان باگ base.html). #}
</div>
</body>
<script>
+315 -149
View File
@@ -24,7 +24,7 @@
-webkit-overflow-scrolling: touch;
}
@media (min-width: 1024px) {
.ez-stats { grid-auto-flow: row; grid-template-columns: repeat(6, 1fr); overflow: visible; }
.ez-stats { grid-auto-flow: row; grid-template-columns: repeat(4, 1fr); overflow: visible; }
}
.ez-stat {
display: flex; flex-direction: column; gap: .18rem;
@@ -41,6 +41,7 @@
.ez-stat.warning { border-top-color: #d97706; }
.ez-stat.link { border-top-color: #0284c7; }
.ez-stat.violet { border-top-color: #7c3aed; }
.ez-stat.followup { border-top-color: #0e7490; }
.ez-stat-top {
display: flex; align-items: baseline; justify-content: space-between; gap: .4rem;
@@ -69,21 +70,84 @@
}
.ez-legend b { font-variant-numeric: tabular-nums; color: #334155; }
/* ---------- ابزارک‌ها ---------- */
.ez-toolbar {
display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin: 0 0 .75rem;
/* ---------- نوار تاریخ (نمای «نیازمند پیگیری») ---------- */
.ez-datebar {
display: flex; flex-wrap: wrap; align-items: flex-end; gap: .4rem;
padding: .5rem .6rem; margin: .5rem 0 .4rem;
background: #f8fbfd; border: 1px solid #d9e8f0; border-radius: .5rem;
}
.ez-toolbar .button { margin: 0 !important; }
.ez-seg {
display: inline-flex; border: 1px solid #e2e8f0; border-radius: .5rem; overflow: hidden;
.ez-datebar .button { margin: 0 !important; }
.ez-dateform { margin: 0; }
.ez-datefield { display: flex; flex-direction: column; gap: .15rem; margin: 0; }
.ez-datefield-row { display: flex; gap: .25rem; align-items: center; }
.ez-datefield-row .input {
height: 2.1rem; font-size: .78rem; width: 8.5rem;
font-variant-numeric: tabular-nums; direction: ltr; text-align: center;
}
.ez-seg a {
padding: .3rem .6rem; font-size: .72rem; color: #475569; text-decoration: none;
background: #fff; border-inline-end: 1px solid #e2e8f0; white-space: nowrap;
.ez-datenote {
flex: 1 1 100%; font-size: .7rem; color: #64748b; line-height: 1.7;
}
.ez-seg a:last-child { border-inline-end: 0; }
.ez-seg a:hover { background: #f1f5f9; }
.ez-seg a.is-active { background: #33b5d3; color: #fff; font-weight: 700; }
.ez-datenote b { color: #334155; }
@media (min-width: 700px) {
.ez-datenote { flex: 1 1 auto; }
}
/* ---------- هشدار مینیمال «پیگیری‌های عقب‌افتاده» ---------- */
.ez-overdue-alert,
.ez-overdue-mode {
flex: 1 1 100%; display: inline-flex; align-items: center; gap: .35rem;
font-size: .72rem; line-height: 1.6; text-decoration: none;
}
.ez-overdue-alert { color: #b91c1c; font-weight: 600; }
.ez-overdue-alert:hover { color: #991b1b; text-decoration: underline; }
.ez-overdue-alert b { font-variant-numeric: tabular-nums; font-size: .8rem; }
.ez-overdue-dot {
width: .42rem; height: .42rem; border-radius: 50%;
background: #dc2626; flex: 0 0 auto;
}
.ez-overdue-mode {
color: #7f1d1d; background: #fef2f2;
border: 1px solid #fecaca; border-radius: .375rem; padding: .2rem .5rem;
}
.ez-overdue-mode b { font-variant-numeric: tabular-nums; }
.ez-overdue-mode a {
color: #b91c1c; font-weight: 700; text-decoration: none;
margin-inline-start: .35rem;
}
.ez-overdue-mode a:hover { text-decoration: underline; }
/* ---------- نوار فیلتر ---------- */
.ez-filters {
display: flex; flex-wrap: wrap; align-items: flex-end;
gap: .5rem; margin: .6rem 0 .4rem;
}
.ez-filter { display: flex; flex-direction: column; gap: .15rem; margin: 0; }
.ez-filter-grow { flex: 1 1 18rem; min-width: 14rem; }
.ez-filter-lbl { font-size: .68rem; color: #64748b; font-weight: 700; }
.ez-filters .select select { font-size: .76rem; height: 2.1rem; }
.ez-search { display: flex; gap: .25rem; align-items: center; }
.ez-search .input { height: 2.1rem; font-size: .76rem; }
.ez-search .button { margin: 0 !important; }
.ez-clear { margin: 0 !important; }
/* ---------- سرستون‌های مرتب‌شدنی ---------- */
.ez-th > a {
display: inline-flex; align-items: center; gap: .15rem;
color: #06323d; text-decoration: none; white-space: nowrap;
}
.ez-th > a:hover { text-decoration: underline; }
.ez-th.is-sorted > a { color: #0b4e60; }
.ez-arrow { font-size: .62rem; opacity: .55; }
.ez-th.is-sorted .ez-arrow { opacity: 1; }
/* ---------- تفکیک تأخیر (کلیک‌پذیر) ---------- */
.ez-legend a {
color: #64748b; text-decoration: none; padding: .05rem .35rem;
border-radius: .25rem; white-space: nowrap;
}
.ez-legend a:hover { background: #f1f5f9; }
.ez-legend a.is-active { background: #33b5d3; color: #fff; }
.ez-legend a.is-active b { color: #fff; }
/* ---------- جدول ---------- */
.ez-table { width: 100%; margin: 0; font-size: .82rem; border-collapse: collapse; }
@@ -196,134 +260,231 @@
<div class="ez-wrap">
<div class="columns m-0">
<div class="column is-12 p-0">
{# ===== نوار آمار: هر عدد لینک فیلتر ===== #}
{# ===== نوار آمار: چهار کارت، هر عدد لینک فیلتر ===== #}
<div class="ez-stats-head">
<p class="ez-stats-title">وضعیت مشتریانِ عقب‌افتاده</p>
<p class="ez-stats-title">وضعیت پیگیری مشتریان</p>
<p class="ez-stats-hint">
«عقب‌افتاده» یعنی از آخرین بازدید
<b>بیش از {{ counts.period_months }} ماه</b> گذشته باشد.
روی هر کارت بزنید تا فقط همان گروه نمایش داده شود؛
<b>کل = بدون قرار + با قرار</b>.
سه کارتِ آخر سه نوع قرارِ جدا هستند؛ یک مشتری می‌تواند بیش از
یکی داشته باشد، پس جمع آن‌ها ممکن است از «با قرار» بیشتر شود.
<b>کل = تعیین‌تکلیف‌نشده + تعیین‌تکلیف‌شده</b>.
</p>
</div>
<div class="ez-stats">
<a href="?view=all&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="ez-stat total {% if view == 'all' %}is-active{% endif %}"
title="همهٔ مشتریان عقب‌افتاده، با هر وضعیت قرار">
{# کارت «نیازمند پیگیری امروز» — مستقل از «عقب‌افتاده»؛ بازدیدها حذف می‌شوند. #}
<a href="{{ view_links.today }}"
class="ez-stat followup {% if view == followup_view %}is-active{% endif %}"
title="مشتریانی که موعد پیگیری/تماسشان امروز است و برایشان بازدید برنامه‌ریزی نشده">
<span class="ez-stat-top">
<span class="ez-lbl">کل عقب‌افتاده‌ها</span>
<span class="ez-num">{{ counts.total }}</span>
<span class="ez-lbl">نیازمند پیگیری امروز</span>
<span class="ez-num has-text-info">{{ counts.followup_today }}</span>
</span>
<span class="ez-desc">همه، فارغ از داشتن یا نداشتن قرار</span>
<span class="ez-desc">موعد تماسشان امروز است — بازدید ندارند</span>
</a>
<a href="?view=uncoordinated&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="ez-stat danger {% if view == 'uncoordinated' %}is-active{% endif %}"
title="مشتریانی که هنوز هیچ قرار بعدی برایشان ثبت نشده است">
<a href="{{ view_links.all }}"
class="ez-stat total {% if view == 'all' %}is-active{% endif %}"
title="همهٔ مشتریانی که بازدیدشان عقب افتاده، با هر وضعیت پیگیری">
<span class="ez-stat-top">
<span class="ez-lbl">بدون قرار</span>
<span class="ez-lbl">همهٔ مشتریان عقب‌افتاده</span>
<span class="ez-num">{{ counts.total }}</span>
</span>
<span class="ez-desc">با هر وضعیت پیگیری</span>
</a>
<a href="{{ view_links.uncoordinated }}"
class="ez-stat danger {% if view == 'uncoordinated' %}is-active{% endif %}"
title="مشتریانی که هنوز هیچ وقت بازدید/پیگیری برایشان تعیین نشده است">
<span class="ez-stat-top">
<span class="ez-lbl">تعیین تکلیف نشده</span>
<span class="ez-num has-text-danger">{{ counts.uncoordinated }}</span>
</span>
<span class="ez-desc">هنوز وقت بازدید تعیین نشده — نیاز به تماس</span>
</a>
<a href="?view=coordinated&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
<a href="{{ view_links.coordinated }}"
class="ez-stat success {% if view == 'coordinated' %}is-active{% endif %}"
title="مشتریانی که وقت بازدید بعدی برایشان مشخص شده است">
title="مشتریانی که وقت بازدید یا پیگیریِ بعدی برایشان تعیین شده است">
<span class="ez-stat-top">
<span class="ez-lbl">با قرار</span>
<span class="ez-lbl">تعیین تکلیف شده</span>
<span class="ez-num has-text-success">{{ counts.coordinated }}</span>
</span>
<span class="ez-desc">وقت بازدیدشان مشخص است (هر دو نوع قرار)</span>
</a>
<a href="?view=manual&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="ez-stat warning {% if view == 'manual' %}is-active{% endif %}"
title="قرار تعویق که از فرم «هماهنگی» ثبت شده است">
<span class="ez-stat-top">
<span class="ez-lbl">قرار هماهنگی</span>
<span class="ez-num has-text-warning-dark">{{ counts.manual }}</span>
</span>
<span class="ez-desc">تعویق دستی از فرم هماهنگی (مثلاً «ماه بعد زنگ بزنید»)</span>
</a>
<a href="?view=scheduled&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="ez-stat link {% if view == 'scheduled' %}is-active{% endif %}"
title="اقدام «بازدید» با تاریخ آینده">
<span class="ez-stat-top">
<span class="ez-lbl">بازدید برنامه‌ریزی‌شده</span>
<span class="ez-num has-text-link">{{ counts.scheduled }}</span>
</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>
<span class="ez-desc">وقت بعدی‌شان مشخص است</span>
</a>
</div>
{# تفکیک مدت تأخیر در نمای جاری #}
<div class="ez-legend">
<span>میزان تأخیر در همین نما:</span>
<span><b>{{ counts.buckets.lt30 }}</b> تا ۳۰ روز</span>
<span><b>{{ counts.buckets.30to90 }}</b> ۳۰ تا ۹۰ روز</span>
<span><b>{{ counts.buckets.90to180 }}</b> ۹۰ تا ۱۸۰ روز</span>
<span><b>{{ counts.buckets.gt180 }}</b> بیش از ۶ ماه</span>
</div>
{# ===== نوار تاریخ در نمای «نیازمند پیگیری» ===== #}
{% if is_followup_view %}
<div class="ez-datebar">
<a href="{{ followup_nav.prev }}" class="button is-small is-light"
title="روز قبل">‹ روز قبل</a>
{# ===== ابزارک‌ها: جستجو و مرتب‌سازی ===== #}
<div class="ez-toolbar">
<a href="#" id="openSearchBox" class="button is-info is-outlined is-rounded is-small">
جستجو{% if searchVal %}: «{{ searchVal }}»{% endif %}
</a>
<form method="get" class="ez-dateform">
<input type="hidden" name="view" value="{{ followup_view }}">
{% if searchVal %}<input type="hidden" name="searchVal" value="{{ searchVal }}">{% endif %}
{% if searchVal %}<input type="hidden" name="searchOn" value="{{ searchOn }}">{% endif %}
<label class="ez-datefield">
<span class="ez-filter-lbl">تاریخ پیگیری (شمسی)</span>
<span class="ez-datefield-row">
<input type="text" name="fdate" id="followupDatePicker"
value="{{ followup_date_jalali }}" class="input is-small ss02"
placeholder="۱۴۰۵/۰۸/۲۹" autocomplete="off" inputmode="numeric">
<button type="submit" class="button is-info is-small">نمایش</button>
</span>
</label>
</form>
<span class="is-size-7 has-text-grey">مرتب‌سازی</span>
<span class="ez-seg">
<a href="?view={{ view }}&sort=oldest{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="{% if sort == 'oldest' %}is-active{% endif %}">قدیمی‌ترین</a>
<a href="?view={{ view }}&sort=overdue{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="{% if sort == 'overdue' %}is-active{% endif %}">بیشترین تأخیر</a>
<a href="?view={{ view }}&sort=newest{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="{% if sort == 'newest' %}is-active{% endif %}">جدیدترین</a>
<a href="{{ followup_nav.next }}" class="button is-small is-light"
title="روز بعد">روز بعد ›</a>
{% if not followup_nav.is_today %}
<a href="{{ followup_nav.today }}" class="button is-success is-light is-small">
بازگشت به امروز
</a>
{% endif %}
<span class="ez-datenote">
{% if followup_nav.is_today %}
فقط موعدهای <b>امروز</b> نمایش داده می‌شوند.
{% else %}
نمایش موعدهای <b>{{ followup_date_jalali }}</b> (نه امروز).
{% endif %}
مشتریانی که برایشان <b>بازدید</b> گذاشته شده در این فهرست نمی‌آیند.
</span>
{% if searchVal %}
<a href="?view={{ view }}&sort={{ sort }}" class="button is-small is-light">حذف جستجو</a>
{# هشدار مینیمال: موعدهایی که گذشته‌اند و هنوز تعیین تکلیف نشده‌اند. #}
{% if counts.followup_overdue and not show_missed %}
<a class="ez-overdue-alert"
href="{{ followup_overdue_link }}"
title="موعد پیگیری‌شان گذشته و هنوز تعیین تکلیف نشده‌اند — برای دیدن فهرستشان کلیک کنید">
<span class="ez-overdue-dot" aria-hidden="true"></span>
<b>{{ counts.followup_overdue }}</b> مورد نیازمند پیگیری از روزهای قبل باقی مانده
</a>
{% endif %}
{% if show_missed %}
<span class="ez-overdue-mode">
نمایش <b>{{ counts.followup_overdue }}</b> پیگیریِ عقب‌افتاده
<a href="{{ followup_ontime_link }}" title="بازگشت به فهرست امروز">✕ بازگشت به امروز</a>
</span>
{% endif %}
</div>
{% endif %}
{# ===== نوار فیلتر و جست‌وجو: یک ردیف، همهٔ کنترل‌ها ===== #}
<form class="ez-filters" method="get" action="">
{% if is_followup_view %}
<input type="hidden" name="fdate" value="{{ followup_date_jalali }}">
{% endif %}
{# حالت «فقط عقب‌افتاده‌ها» هنگام جست‌وجو/فیلتر حفظ شود. #}
{% if show_missed %}
<input type="hidden" name="missed" value="1">
{% endif %}
<label class="ez-filter">
<span class="ez-filter-lbl">نوع قرار</span>
<span class="select is-small">
<select name="view" class="ez-auto-submit">
<option value="today" {% if is_followup_view %}selected{% endif %}>نیازمند پیگیری (تاریخ انتخابی)</option>
<option value="all" {% if view == 'all' %}selected{% endif %}>همه (با و بدون قرار)</option>
<option value="uncoordinated" {% if view == 'uncoordinated' %}selected{% endif %}>تعیین تکلیف نشده</option>
<option value="coordinated" {% if view == 'coordinated' %}selected{% endif %}>تعیین تکلیف شده (همه)</option>
<option value="manual" {% if view == 'manual' %}selected{% endif %}>تعویق دستی</option>
<option value="scheduled" {% if view == 'scheduled' %}selected{% endif %}>بازدید برنامه‌ریزی‌شده</option>
<option value="coordination" {% if view == 'coordination' %}selected{% endif %}>پیگیری تلفنی</option>
</select>
</span>
</label>
{% if not is_followup_view %}
<label class="ez-filter">
<span class="ez-filter-lbl">میزان تأخیر</span>
<span class="select is-small">
<select name="delay" class="ez-auto-submit">
<option value="" {% if not delay %}selected{% endif %}>همه</option>
<option value="lt30" {% if delay == 'lt30' %}selected{% endif %}>تا ۳۰ روز ({{ counts.buckets.lt30 }})</option>
<option value="30to90" {% if delay == '30to90' %}selected{% endif %}>۳۰ تا ۹۰ روز ({{ counts.buckets.30to90 }})</option>
<option value="90to180" {% if delay == '90to180' %}selected{% endif %}>۹۰ تا ۱۸۰ روز ({{ counts.buckets.90to180 }})</option>
<option value="gt180" {% if delay == 'gt180' %}selected{% endif %}>بیش از ۶ ماه ({{ counts.buckets.gt180 }})</option>
</select>
</span>
</label>
{% endif %}
<label class="ez-filter">
<span class="ez-filter-lbl">مرتب‌سازی</span>
<span class="select is-small">
<select name="sort" class="ez-auto-submit">
<option value="last_visit" {% if sort == 'last_visit' and sort_dir == 'asc' %}selected{% endif %}>قدیمی‌ترین بازدید</option>
<option value="last_visit-desc" {% if sort == 'last_visit' and sort_dir == 'desc' %}selected{% endif %}>جدیدترین بازدید</option>
<option value="overdue" {% if sort == 'overdue' %}selected{% endif %}>بیشترین تأخیر</option>
<option value="name" {% if sort == 'name' %}selected{% endif %}>نام (الفبا)</option>
<option value="status" {% if sort == 'status' %}selected{% endif %}>وضعیت قرار</option>
<option value="coordination" {% if sort == 'coordination' %}selected{% endif %}>تاریخ پیگیری</option>
</select>
</span>
</label>
<label class="ez-filter ez-filter-grow">
<span class="ez-filter-lbl">جست‌وجو</span>
<span class="ez-search">
<input type="text" name="searchVal" value="{{ searchVal }}"
class="input is-small" placeholder="نام، تلفن، کد یا آدرس">
<span class="select is-small">
<select name="searchOn">
<option value="name" {% if searchOn == 'name' %}selected{% endif %}>نام</option>
<option value="phone" {% if searchOn == 'phone' %}selected{% endif %}>تلفن</option>
<option value="code" {% if searchOn == 'code' %}selected{% endif %}>کد</option>
<option value="address" {% if searchOn == 'address' %}selected{% endif %}>آدرس</option>
</select>
</span>
<button type="submit" class="button is-info is-small">جست‌وجو</button>
</span>
</label>
{% if has_filters %}
<a href="{{ clear_link }}" class="button is-light is-small ez-clear">✕ حذف فیلترها</a>
{% endif %}
</form>
{# تفکیک مدت تأخیر در نمای جاری (کلیک‌پذیر) #}
{% if not is_followup_view %}
<div class="ez-legend">
<span>میزان تأخیر در همین نما:</span>
<a href="{{ delay_links.ez_all }}" class="{% if not delay %}is-active{% endif %}">
<b>{{ counts.view_total }}</b> همه
</a>
<a href="{{ delay_links.lt30 }}" class="{% if delay == 'lt30' %}is-active{% endif %}">
<b>{{ counts.buckets.lt30 }}</b> تا ۳۰ روز
</a>
<a href="{{ delay_links.30to90 }}" class="{% if delay == '30to90' %}is-active{% endif %}">
<b>{{ counts.buckets.30to90 }}</b> ۳۰ تا ۹۰ روز
</a>
<a href="{{ delay_links.90to180 }}" class="{% if delay == '90to180' %}is-active{% endif %}">
<b>{{ counts.buckets.90to180 }}</b> ۹۰ تا ۱۸۰ روز
</a>
<a href="{{ delay_links.gt180 }}" class="{% if delay == 'gt180' %}is-active{% endif %}">
<b>{{ counts.buckets.gt180 }}</b> بیش از ۶ ماه
</a>
</div>
{% endif %}
<div class="table-container">
<table class="table ez-table">
<colgroup>
<col style="width: 21%">
<col style="width: 12%">
<col style="width: 10%">
<col style="width: 10%">
<col style="width: 10%">
<col style="width: 9%">
<col style="width: 8%">
<col style="width: 9%">
<col style="width: 11%">
{% for col in table_columns %}
<col style="width: {{ col.width }}%">
{% endfor %}
</colgroup>
<thead>
<tr>
<th>نام و نام خانوادگی</th>
<th>وضعیت قرار</th>
<th>قرار دستی</th>
<th>بازدید برنامه‌ریزی‌شده</th>
<th>هماهنگی</th>
<th>آخرین بازدید</th>
<th>تأخیر</th>
<th>تلفن</th>
<th>آدرس</th>
{% for col in sort_header %}
<th class="ez-th {% if col.active %}is-sorted{% endif %}">
<a href="{{ col.link }}" title="مرتب‌سازی بر اساس {{ col.label }}">
{{ col.label }}<span class="ez-arrow">{% if col.active %}{% if col.dir == 'asc' %}▲{% else %}▼{% endif %}{% else %}⇅{% endif %}</span>
</a>
</th>
{% endfor %}
</tr>
</thead>
<tbody>
@@ -364,6 +525,7 @@
<td data-label="آخرین بازدید" class="ez-num ez-nowrap lastView">
{% if appt.last_visit %}{{ appt.last_visit|toJalali }}{% else %}<span class="ez-muted">بدون بازدید</span>{% endif %}
</td>
{% if 'overdue' not in hidden_columns %}
<td data-label="تأخیر" class="ez-nowrap">
{% if appt.overdue_days >= 180 %}
<span class="ez-late lv3">{{ appt.overdue_days }} روز</span>
@@ -375,6 +537,7 @@
<span class="ez-late lv0">{{ appt.overdue_days }} روز</span>
{% endif %}
</td>
{% endif %}
{% endwith %}
<td data-label="تلفن" class="ez-nowrap">
@@ -397,8 +560,14 @@
</tr>
{% empty %}
<tr>
<td colspan="9" class="has-text-centered py-5">
<span class="has-text-grey">موردی در این نما پیدا نشد.</span>
<td colspan="{{ column_count }}" class="has-text-centered py-5">
<span class="has-text-grey">
{% if is_followup_view %}
برای تاریخ {{ followup_date_jalali }} موردی برای پیگیری نیست.
{% else %}
موردی در این نما پیدا نشد.
{% endif %}
</span>
</td>
</tr>
{% endfor %}
@@ -409,7 +578,7 @@
{# ===== صفحه‌بندی ===== #}
<div class="is-flex is-align-items-center is-justify-content-space-between mt-3 mb-5">
{% if customers.has_previous %}
<a class="button is-danger is-small" href="?view={{ view }}&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}&page={{ customers.previous_page_number }}">صفحه قبل</a>
<a class="button is-danger is-small" href="{{ page_prev }}">صفحه قبل</a>
{% else %}<span></span>{% endif %}
<span class="is-size-7 has-text-grey">
@@ -418,60 +587,57 @@
</span>
{% if customers.has_next %}
<a class="button is-danger is-small" href="?view={{ view }}&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}&page={{ customers.next_page_number }}">صفحه بعد</a>
<a class="button is-danger is-small" href="{{ page_next }}">صفحه بعد</a>
{% else %}<span></span>{% endif %}
</div>
</div>
</div>
</div>
<div class="modal SearchBox">
<div class="modal-background closeSearch"></div>
<div class="modal-content">
<form method="get" class="columns is-mobile is-multiline">
{# حفظ نما و ترتیب هنگام جست‌وجو #}
<input type="hidden" name="view" value="{{ view }}">
<input type="hidden" name="sort" value="{{ sort }}">
<div class="field column is-7-desktop is-12-mobile m-0">
<label class="label">جستجو کنید</label>
<div class="control">
<input name="searchVal" class="input search" type="text" value="{{ searchVal }}" placeholder="نام، تلفن، کد یا آدرس">
</div>
</div>
<div class="field column is-5-desktop is-12-mobile m-0">
<label class="label">فیلد</label>
<div class="select is-fullwidth">
<select name="searchOn">
<option value="name" {% if searchOn == 'name' %}selected{% endif %}>نام و نام خانوادگی</option>
<option value="phone" {% if searchOn == 'phone' %}selected{% endif %}>تلفن</option>
<option value="address" {% if searchOn == 'address' %}selected{% endif %}>آدرس</option>
<option value="code" {% if searchOn == 'code' %}selected{% endif %}>کد مشتری</option>
</select>
</div>
</div>
<div class="field column is-12-desktop is-12-mobile m-0">
<input class="button is-success is-fullwidth" id="searchbtn" value="جستجو مشتری" type="submit">
</div>
</form>
</div>
<button class="modal-close is-large closeSearch" aria-label="close"></button>
</div>
{% endblock %}
{% block jsEnd %}
<script>
$(document).ready(function() {
$("#openSearchBox").click(function(e) {
e.preventDefault();
$(".SearchBox").addClass("is-active");
});
$(".closeSearch").click(function() {
$(".SearchBox").removeClass("is-active");
// تغییر هر دراپ‌داون ⇒ ارسال خودکار فرم فیلترها.
$(".ez-auto-submit").on('change', function () {
$(this).closest('form').trigger('submit');
});
// مقدار «مرتب‌سازی» با علامتِ جهت ترکیب می‌شود (مثلاً last_visit-desc)
// تا یک دراپ‌داون بتواند هر دو جهت را پوشش دهد.
$(".ez-filters select[name=sort]").on('change', function () {
var value = $(this).val() || '';
var form = $(this).closest('form');
var dash = value.lastIndexOf('-');
if (dash > -1) {
form.find("input[name=sort]").val(value.slice(0, dash));
form.find("input[name=dir]").val(value.slice(dash + 1));
} else {
form.find("input[name=sort]").val(value);
form.find("input[name=dir]").val('');
}
});
// تقویم شمسی برای ناوبری روزهای نمای «نیازمند پیگیری».
// همان کامپوننت pDatepicker که در بقیهٔ صفحه‌ها استفاده شده است.
{% if is_followup_view %}
var $followupDate = $("#followupDatePicker");
if ($followupDate.length && typeof $followupDate.pDatepicker === 'function') {
$followupDate.pDatepicker({
initialValue: false,
initialValueType: 'persian',
format: 'YYYY/MM/DD',
autoClose: true,
calendar: {
persian: { locale: 'fa', leapYearMode: 'astronomical' }
},
onSelect: function () {
// انتخاب تاریخ ⇒ فوراً همان روز نمایش داده شود.
$followupDate.closest('form').trigger('submit');
}
});
}
{% endif %}
});
</script>
{% endblock %}
+381 -76
View File
@@ -19,19 +19,47 @@ from ..sms.phones import (normalize_primary_values,
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, last_visit_map, visit_due_at,
visit_due_date, visit_period_months)
appointment_sources_map, as_aware,
followups_on_date, last_visit_map, overdue_followups,
visit_due_at, visit_due_date, visit_period_months)
# نماهای صفحهٔ «انتظار» (فیلتر ?view=). پیش‌فرض = فقط «بدون قرار» تا رفتار
# قبلی صفحه برای کاربران حفظ شود.
ENTEZAR_VIEWS = ('uncoordinated', 'manual', 'scheduled', 'coordination',
# نماهای صفحهٔ «انتظار» (فیلتر ?view=).
# پیش‌فرض = «نیازمند پیگیری امروز»: فهرست کاریِ روزانهٔ کارشناس (موعد تماس‌های
# امروز). سایر نماها با کارت‌ها/دراپ‌داون «نوع قرار» در دسترس‌اند.
# ``today`` یک نمای **مستقل** است: پایهٔ آن «عقب‌افتاده بودن» نیست — مشتری
# سالمی هم که با او قرار تماس گذاشته شده باید در این فهرست دیده شود.
ENTEZAR_VIEWS = ('today', 'uncoordinated', 'manual', 'scheduled', 'coordination',
'coordinated', 'all')
ENTEZAR_DEFAULT_VIEW = 'uncoordinated'
# حالت‌های مرتب‌سازی (?sort=):
# oldest ⇒ قدیمی‌ترین بازدید اول (پیش‌فرض/اولویت کارشناس)
# newest ⇒ جدیدترین بازدید اول
# overdue ⇒ بیشترین تأخیر اول
ENTEZAR_SORTS = ('oldest', 'newest', 'overdue')
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
@@ -359,9 +387,26 @@ def entezar(request):
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'
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()
@@ -370,32 +415,47 @@ def entezar(request):
# دوره فقط یک بار خوانده می‌شود (جلوگیری از N+1 در حلقهٔ محاسبهٔ تأخیر).
period_months = visit_period_months()
# مرز تاریخ به وقت محلی (نه UTC) تا صفحه و اسکنر پیامک یک جواب بدهند.
due_cut_date = due_cut.astimezone(timezone.get_current_timezone()).date()
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,
)
# جست‌وجو: نام، آدرس، تلفن (همه شماره‌ها) و کد شناسایی.
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
customers_objj = _apply_search(customers_objj)
# مشتریان عقب‌افتاده: بدون بازدید معتبر، یا بازدید معتبرشان پیش از مقطع دوره.
stale_ids = [
@@ -431,12 +491,79 @@ def entezar(request):
'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 = now.astimezone(timezone.get_current_timezone()).date()
# محاسبه روی کلِ نمای جاری انجام می‌شود تا شمارنده‌های بازه‌ها با فیلتر
# «میزان تأخیر» هم‌خوان باشند (اول نمای جاری، بعد فیلتر تأخیر).
today = today_date
overdue_days = {}
for customer_id in selected:
anchor = visits.get(customer_id)
@@ -448,42 +575,7 @@ def entezar(request):
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:
@@ -495,17 +587,110 @@ def entezar(request):
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,
@@ -513,20 +698,118 @@ def entezar(request):
try:
entezar_bazdid = CacheTable.objects.get(key='9mahbazdid')
# کارت داشبورد = «کارِ فوری» = بدون قرار.
# کارت داشبورد = «کارِ فوری» = تعیین تکلیف نشده.
entezar_bazdid.value = counts['uncoordinated']
entezar_bazdid.save()
except CacheTable.DoesNotExist:
pass
paginator = Paginator(customers_objj, 20)
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 = sources.get(customer.id) or {}
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] = {
@@ -537,14 +820,36 @@ def entezar(request):
'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,
'sort': sort, 'sorts': ENTEZAR_SORTS,
'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,
})
+194 -19
View File
@@ -402,33 +402,24 @@ def coordination_holds_map(*, now=None):
)
def manual_holds_map(*, now=None):
"""قرارهای دستی (ستون ``visit_hold_until``) — **با اعتبارسنجی**.
def _valid_manual_holds(holds):
"""اعتبارسنجی قرارهای دستی — قاعدهٔ قطعی «ستون، مرجع نیست».
قاعدهٔ قطعی: ستون به‌تنهایی معتبر نیست. یک قرار دستی فقط وقتی اثر دارد که:
ورودی ``{customer_id: visit_hold_until}`` و خروجی همان نگاشت، فقط برای
قرارهایی که معتبرند:
۱. تاریخش از حالا جلوتر باشد،
۲. یک اقدام «هماهنگی» فعال (``waite``/``finish``) برای مشتری وجود داشته باشد،
۳. و **آخرین فعالیتِ مشتری همان هماهنگی باشد** — اگر پس از هماهنگی، اقدام
۱. یک اقدام «هماهنگی» فعال (``waite``/``finish``) برای مشتری وجود دارد،
۲. و **آخرین فعالیتِ مشتری همان هماهنگی است** — اگر پس از هماهنگی، اقدام
دیگری (به‌ویژه «بازدید») ثبت شده باشد، قرار باطل است.
بند ۳ همان چیزی است که «ثبت بازدید پس از هماهنگی» را خودکار بی‌اثر می‌کند؛
بدون آن، قرارِ قدیمی تا تاریخش باقی می‌ماند و مشتری در فهرست «با قرار»
می‌ماند، هرچند بازدید (یا برنامهٔ بازدید) جای هماهنگی را گرفته است.
بند ۲ همان چیزی است که «ثبت بازدید پس از هماهنگی» را خودکار بی‌اثر می‌کند.
نتیجه: حذف/لغو هماهنگی، یا هر اقدام جدیدتر از آن ⇒ قرار بی‌اثر می‌شود؛
و کهنه‌ماندن ستون هیچ اثر مخربی ندارد.
این تابع جدا شده تا هم :func:`manual_holds_map` (قرارهای آینده) و هم
:func:`followups_on_date` (قرارهای یک روز مشخص، حتی گذشته) از **یک قاعدهٔ
واحد** استفاده کنند و دو تعریف واگرا شکل نگیرد.
"""
now = _now(now)
rows = (
Customer.objects.filter(visit_hold_until__gt=now)
.values_list("id", "visit_hold_until")
.iterator()
)
holds = {customer_id: as_aware(until) for customer_id, until in rows}
if not holds:
return {}
customer_ids = list(holds)
# مرجع اعتبار: وجود یک اقدام «هماهنگیِ فعال» برای همان مشتری (نه لزوماً با
@@ -469,6 +460,190 @@ def manual_holds_map(*, now=None):
return valid
def manual_holds_map(*, now=None):
"""قرارهای دستی **آینده** (ستون ``visit_hold_until``) — با اعتبارسنجی.
برای فهرست انتظار و آمار؛ قاعدهٔ اعتبار در :func:`_valid_manual_holds`.
"""
now = _now(now)
rows = (
Customer.objects.filter(visit_hold_until__gt=now)
.values_list("id", "visit_hold_until")
.iterator()
)
return _valid_manual_holds(
{customer_id: as_aware(until) for customer_id, until in rows}
)
# ---------------------------------------------------------------------------
# «نیازمند پیگیری» در یک روز مشخص
# ---------------------------------------------------------------------------
def _local_day_bounds(target_date):
"""بازهٔ ``[آغاز روز، پایان روز]`` به وقت محلی (aware)."""
current_tz = timezone.get_current_timezone()
start = datetime.combine(target_date, time.min)
end = datetime.combine(target_date, time.max)
try:
return (
timezone.make_aware(start, current_tz),
timezone.make_aware(end, current_tz),
)
except Exception:
return (
timezone.make_aware(start, current_tz, is_dst=False),
timezone.make_aware(end, current_tz, is_dst=False),
)
def _pending_visit_customer_ids(*, since):
"""مشتریانی که **قرار بازدید** دارند و نباید در فهرست پیگیری بیایند.
قاعدهٔ محصولی: «اگر برای مشتری بازدید گذاشته شده، دیگر لازم نیست تماس
بگیریم». پس هر مشتری با اقدام ``bazdid + waite`` که تاریخش از ``since``
(آغاز روز مورد نظر) عقب‌تر نباشد، از فهرست پیگیری **حذف** می‌شود.
چرا ``>= since`` و نه «هر بازدیدی»؟ بازدیدِ در انتظارِ بسیار قدیمی
(backlog بسته‌نشده) قرار فعال نیست؛ آن مشتری هنوز نیاز به تماس دارد.
"""
return set(
Action.objects.filter(
actionType=Action.ActionType.BAZDID,
status=Action.Status.WAITE,
)
.filter(
models.Q(actionDateTime__gte=since)
| models.Q(actionDateTime__isnull=True, finishDateTime__gte=since)
)
.values_list("customer_id", flat=True)
)
def followups_on_date(target_date, *, now=None):
"""مشتریانِ «نیازمند پیگیری» در یک تاریخ مشخص (تقویم شمسی/میلادی).
«نیازمند پیگیری» یعنی موعدی که با مشتری توافق شده **همان روز** است:
* **قرار دستی** — ``Customer.visit_hold_until`` که تاریخ آن همان روز است
(با همان اعتبارسنجی :func:`_valid_manual_holds`).
* **هماهنگی** — اقدام ``hamahangi`` با ``actionDateTime`` (وگرنه
``finishDateTime``) در همان روز، با وضعیت ``waite`` یا ``finish``.
تفاوت با :func:`appointment_sources_map`: آن تابع فقط **آینده** را می‌بیند
(برای تصمیم «خارج از فهرست انتظار»)، ولی این یکی **هر روزی** — گذشته،
امروز یا آینده — را می‌بیند تا کارشناس بتواند با تقویم عقب/جلو برود.
خروجی: ``{customer_id: {منبع: تاریخ}}`` مرتب بر اساس زودترین ساعت موعد.
مشتریانی که قرار **بازدید** دارند در خروجی نیستند.
"""
day_start, day_end = _local_day_bounds(target_date)
result = {}
# ۱) قرارهای دستی همان روز (مستقل از گذشته/آینده بودن).
rows = (
Customer.objects.filter(
visit_hold_until__gte=day_start, visit_hold_until__lte=day_end,
)
.values_list("id", "visit_hold_until")
.iterator()
)
for customer_id, until in _valid_manual_holds(
{cid: as_aware(when) for cid, when in rows}
).items():
result.setdefault(customer_id, {})[SOURCE_MANUAL] = until
# ۲) اقدام‌های «هماهنگی» همان روز.
coordination_rows = (
Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI,
status__in=(Action.Status.WAITE, Action.Status.FINISH),
)
.filter(
models.Q(actionDateTime__gte=day_start, actionDateTime__lte=day_end)
| models.Q(
actionDateTime__isnull=True,
finishDateTime__gte=day_start,
finishDateTime__lte=day_end,
)
)
.values_list("customer_id", "actionDateTime", "finishDateTime")
.iterator()
)
for customer_id, action_dt, finish_dt in coordination_rows:
when = as_aware(action_dt) or as_aware(finish_dt)
if when is None:
continue
bucket = result.setdefault(customer_id, {})
current = bucket.get(SOURCE_COORDINATION)
if current is None or when < current:
bucket[SOURCE_COORDINATION] = when
# ۳) حذف مشتریانی که برایشان «بازدید» گذاشته شده است.
if result:
for customer_id in _pending_visit_customer_ids(since=day_start):
result.pop(customer_id, None)
return result
def overdue_followups(*, now=None, target_date=None):
"""پیگیری‌های **عقب‌افتاده**: موعدشان گذشته و هنوز تعیین تکلیف نشده‌اند.
«عقب‌افتاده» یعنی برای هر مشتری، **آخرین** اقدام هماهنگی (``waite``/``finish``)
تاریخی پیش از امروز دارد؛ یعنی موعد توافق‌شده گذشته و هیچ موعد تازه‌ای
جایگزینش نشده است.
قواعد:
* اگر مشتری هماهنگیِ **امروز یا آینده** داشته باشد، عقب‌افتاده نیست
(موعدش تازه است).
* اگر برایش **قرار بازدید** ثبت شده باشد، از فهرست خارج می‌شود — همان قاعدهٔ
:func:`followups_on_date` («بازدید گذاشته شده ⇒ تماس لازم نیست»).
* هماهنگیِ لغوشده (``cancell``) اصلاً دیده نمی‌شود.
خروجی: ``{customer_id: تاریخِ موعدِ ازدست‌رفته}`` — تا هم شمارش کنیم و هم
بتوانیم کاربر را به همان روز ببریم.
"""
now = _now(now)
today = target_date or now.astimezone(timezone.get_current_timezone()).date()
rows = (
Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI,
status__in=(Action.Status.WAITE, Action.Status.FINISH),
)
.values_list("customer_id", "actionDateTime", "finishDateTime")
.iterator()
)
# آخرین موعد هر مشتری (تاریخ، نه لحظه).
latest = {}
for customer_id, action_dt, finish_dt in rows:
when = as_aware(action_dt) or as_aware(finish_dt)
if when is None:
continue
day = when.astimezone(timezone.get_current_timezone()).date()
current = latest.get(customer_id)
if current is None or day > current:
latest[customer_id] = day
overdue = {cid: day for cid, day in latest.items() if day < today}
if not overdue:
return {}
# حذف مشتریانی که برایشان بازدید گذاشته شده است (قرار بازدید از آن روز).
# «از آن روز» = زودترین موعدِ ازدست‌رفته؛ بازدیدی که بعد از آن گذاشته شده
# تکلیف مشتری را روشن کرده است.
pending_visits = _pending_visit_customer_ids(
since=_local_day_bounds(min(overdue.values()))[0]
)
for customer_id in pending_visits:
overdue.pop(customer_id, None)
return overdue
def appointment_sources_map(*, now=None):
"""نگاشت ``customer_id → {منبع: تاریخ}`` برای هر سه منبع (فقط آینده).
+187 -41
View File
@@ -273,14 +273,15 @@ visit_hold_until = max(مقدار فعلی، تاریخ جدید)
| URL | نمایش | شامل |
|---|---|---|
| `/panel/entezar/` (بدون پارامتر) | **پیش‌فرض = بدون قرار** (رفتار قبلی) | بدون هیچ منبع |
| `/panel/entezar/` (بدون پارامتر) | **پیش‌فرض = نیازمند پیگیری امروز** | موعد تماس‌های امروز |
| `/panel/entezar/?view=today` | نیازمند پیگیری امروز (یا تاریخ `fdate`) | موعد تماس‌های آن روز |
| `?view=uncoordinated` | بدون قرار | بدون هیچ منبع |
| `?view=manual` | قرار دستی | هر مشتری با `manual` |
| `?view=scheduled` | بازدید برنامه‌ریزی‌شده | هر مشتری با `scheduled` |
| `?view=coordination` | هماهنگی | هر مشتری با `coordination` |
| `?view=coordinated` | همهٔ دارای قرار | اجتماع هر سه منبع |
| `?view=all` | همهٔ معوق‌ها | همه |
| مقدار نامعتبر | برگشت به پیش‌فرض، **بدون خطا** | بدون هیچ منبع |
| مقدار نامعتبر | برگشت به پیش‌فرض (`today`)، **بدون خطا** | موعد تماس‌های امروز |
چون یک مشتری می‌تواند چند منبع داشته باشد، در چند فیلتر دیده می‌شود؛ اما در
`?view=coordinated` فقط **یک‌بار** شمرده می‌شود. به همین دلیل جمع سه کارت منبع
@@ -380,48 +381,180 @@ python manage.py test tests
## ۱. نوار آمار (بالای صفحه)
پنج کارت که هر کدام هم **عدد** را نشان می‌دهد و هم **لینک فیلتر** است:
چهار کارت. عنوان‌ها با **زبان کسب‌وکار** انتخاب شده‌اند، نه زبان دیتابیس. هر کارت
هم عدد را نشان می‌دهد و هم **لینک فیلتر** است:
| کارت | چه چیزی را نشان می‌دهد |
| کارت | معنا | توضیح زیر عدد |
|---|---|---|
| **نیازمند پیگیری امروز** | موعد تماسشان **امروز** است و بازدید ندارند — فهرست کارِ امروز | موعد تماسشان امروز است — بازدید ندارند |
| **همهٔ مشتریان عقب‌افتاده** | همه، با هر وضعیت پیگیری | با هر وضعیت پیگیری |
| **تعیین تکلیف نشده** | هنوز هیچ وقت بازدید/پیگیری ندارند | هنوز وقت بازدید تعیین نشده — نیاز به تماس |
| **تعیین تکلیف شده** | وقت بعدی‌شان مشخص است | وقت بعدی‌شان مشخص است |
> **چرا «مشتری» و نه «بازدید»؟** عددِ کارت دوم تعداد **مشتری** است، نه تعداد
> بازدید. یک مشتری می‌تواند ده‌ها بازدید گذشته داشته باشد ولی فقط **یک بار**
> در این فهرست شمرده شود.
### تفکیک نوع قرار — در دراپ‌داون، نه روی کارت
کارت‌ها **عمداً ساده** نگه داشته شده‌اند: فقط عنوان، عدد و یک توضیح. جزئیات سه
نوع قرار (تعویق / بازدید / پیگیری) روی کارت چاپ **نمی‌شود**؛ چون:
- شش عدد روی کارت‌ها باعث سردرگمی می‌شد (بازخورد کاربران).
- ارتفاع کارت‌ها نامساوی و چیدمان موبایل به‌هم می‌ریخت.
- همان اطلاعات از دراپ‌داون **«نوع قرار»** در نوار فیلتر در دسترس است.
> جمع سه منبع می‌تواند از عدد «تعیین تکلیف شده» بیشتر باشد، چون یک مشتری
> می‌تواند چند منبع قرار داشته باشد؛ در `?view=coordinated` هر مشتری فقط
> **یک‌بار** شمرده می‌شود.
## ۱.۱ نمای «نیازمند پیگیری امروز» (`?view=today`) — **پیش‌فرض صفحه**
> **پیش‌فرض:** با باز کردن `/panel/entezar/` (بدون پارامتر)، همین نما نمایش داده
> می‌شود؛ کاربر بلافاصله فهرست کارِ امروز را می‌بیند.
فهرست کاریِ روزانهٔ کارشناس: **با چه کسانی امروز باید تماس بگیریم؟**
### این فهرست از کجا می‌آید؟
دو منبع، هر دو با شرط «موعد = همان روز»:
| منبع | تعریف |
|---|---|
| کل معوق | همهٔ مشتریان عقب‌افتاده (با قرار و بدون قرار) |
| بدون قرار | کسانی که هیچ قراری ندارند — **کار عملیاتی امروز** |
| با قرار | قرار هماهنگی یا بازدید برنامه‌ریزی‌شده دارند |
| قرار هماهنگی | قرار دستی ثبت‌شده روی مشتری |
| بازدید برنامه‌ریزی‌شده | بازدید/هماهنگیِ در جریان از نوع `Action` |
| **هماهنگی** | اقدام `hamahangi` با `status` در (`waite`/`finish`) و `actionDateTime` (وگرنه `finishDateTime`) در همان روز |
| **قرار دستی** | `Customer.visit_hold_until` که تاریخش همان روز است (با همان اعتبارسنجی «ستون، مرجع نیست») |
کارت فعال با حاشیهٔ آبی مشخص می‌شود.
### قاعدهٔ قطعی: «بازدید» اینجا نمی‌آید
## ۲. تفکیک مدت تأخیر
> اگر برای مشتری **قرار بازدید** (`bazdid + waite`) ثبت شده باشد، **هرگز** در این
> فهرست نمایش داده نمی‌شود — حتی اگر برای همان روز هم موعد پیگیری داشته باشد.
زیر نوار آمار، تعداد مشتریان بر اساس **مدت عقب‌افتادگی** نمایش داده می‌شود:
دلیلش روشن است: وقتی پرسنل برای بازدید می‌آید، تماس تلفنی لازم نیست.
- کمتر از ۳۰ روز
**استثنای مهم:** بازدیدهای بسیار قدیمی که هرگز بسته نشده‌اند (backlog) قرار فعال
محسوب نمی‌شوند؛ وگرنه یک رکورد کهنه می‌توانست مشتری را برای همیشه از فهرست
پنهان کند. شرط دقیق: بازدیدِ در انتظار با تاریخ **≥ آغاز روز مورد نظر**.
### تفاوت با فهرست «عقب‌افتاده»
| | «نیازمند پیگیری» | «عقب‌افتاده» |
|---|---|---|
| شرط ورود | موعد تماس = آن روز | از آخرین بازدید بیش از دوره گذشته |
| مشتری سالم | ✅ اگر قرار تماس داشته باشد می‌آید | ❌ نمی‌آید |
| شرط `installDate` | ندارد | دارد |
| ترتیب پیش‌فرض | زودترین ساعت موعد | قدیمی‌ترین بازدید |
### تقویم شمسی (گذشته و آینده)
نوار تاریخ فقط در این نما ظاهر می‌شود و شامل این‌هاست:
- **تقویم شمسی** (`pDatepicker`) برای انتخاب هر روزی
- دکمه‌های **‹ روز قبل** و **روز بعد ›**
- دکمهٔ **بازگشت به امروز** (اگر تاریخ انتخابی امروز نباشد)
```
/panel/entezar/?view=today امروز
/panel/entezar/?view=today&fdate=1405/07/28 روز دیگر (شمسی)
```
- مقدار نامعتبر/خالی ⇒ **امروز** (بدون خطا).
- عدد کارت «نیازمند پیگیری امروز» **همیشه عدد امروز** را نشان می‌دهد، حتی وقتی
در تقویم روز دیگری را می‌بینید.
- فیلتر «میزان تأخیر» و تفکیک آن در این نما **پنهان** می‌شود (مشتری این فهرست
ممکن است اصلاً تأخیری نداشته باشد)؛ اما جست‌وجو و مرتب‌سازی فعال است.
### ستون «تأخیر» در این نما نمایش داده نمی‌شود
این فهرست دربارهٔ «موعد تماس امروز» است، نه عقب‌افتادگی بازدید؛ پس ستون «تأخیر»
فقط در سایر نماها دیده می‌شود. عرض آن (۸٪) به ستون «نام» اضافه می‌شود تا مجموع
عرض ستون‌ها همچنان دقیقاً ۱۰۰٪ بماند (۸ ستون به‌جای ۹).
### هشدار «پیگیری‌های عقب‌افتاده»
اگر موعد پیگیری مشتریانی گذشته باشد و هنوز تعیین تکلیف نشده باشند، زیر نوار تاریخ
یک هشدار مینیمال قرمز با نقطهٔ کوچک نمایش داده می‌شود:
```
● 72 مورد نیازمند پیگیری از روزهای قبل باقی مانده
```
قواعد محاسبه (`visit_policy.overdue_followups`):
- مبنای هر مشتری، **آخرین** اقدام هماهنگی (`waite`/`finish`) است.
- اگر آن تاریخ **پیش از امروز** باشد ⇒ عقب‌افتاده.
- اگر مشتری هماهنگیِ امروز یا آینده داشته باشد ⇒ عقب‌افتاده **نیست**
(یعنی تمدید شده و از فهرست بیرون می‌رود).
- اگر برایش **قرار بازدید** ثبت شده باشد ⇒ حذف می‌شود (همان قاعدهٔ «بازدید گذاشته
شده ⇒ تماس لازم نیست»).
- هماهنگیِ لغوشده (`cancell`) اصلاً دیده نمی‌شود.
کلیک روی هشدار، همان فهرست را باز می‌کند:
```
/panel/entezar/?view=today&missed=1
```
در این حالت یک چیپ قرمز «نمایش N پیگیریِ عقب‌افتاده ✕ بازگشت به امروز» نشان داده
می‌شود و هشدار پنهان می‌گردد. عدد هشدار **دقیقاً** برابر تعداد ردیف‌های همین
فهرست است (هر دو از یک مسیر محاسبه می‌شوند).
## ۲. فیلترها (نوار زیر کارت‌ها)
یک ردیف فشرده با چهار کنترل؛ همه با هم ترکیب‌پذیرند و در URL حفظ می‌شوند.
| کنترل | پارامتر | مقادیر |
|---|---|---|
| نوع قرار | `view` | `all` · `uncoordinated` · `coordinated` · `manual` · `scheduled` · `coordination` |
| میزان تأخیر | `delay` | `lt30` · `30to90` · `90to180` · `gt180` (خالی = همه) |
| مرتب‌سازی | `sort` + `dir` | `last_visit` · `overdue` · `name` · `status` · `coordination` (و هر ستون دیگر) |
| جست‌وجو | `searchVal` + `searchOn` | `name` · `phone` · `code` · `address` |
- تغییر هر دراپ‌داون ⇒ فرم **خودکار** ارسال می‌شود.
- دکمهٔ **✕ حذف فیلترها** فقط وقتی فیلتری فعال است ظاهر می‌شود و همه را به
حالت پیش‌فرض برمی‌گرداند.
- **کارت‌ها هیچ‌وقت با فیلتر تغییر نمی‌کنند** — همیشه کل جامعه را نشان می‌دهند.
فقط فهرست زیرشان فیلتر می‌شود.
## ۳. تفکیک مدت تأخیر (کلیک‌پذیر)
زیر نوار فیلتر، تعداد مشتریان بر اساس **مدت عقب‌افتادگی** در **نمای جاری**
نمایش داده می‌شود و هر عدد **لینک فیلتر** است:
- تا ۳۰ روز
- ۳۰ تا ۹۰ روز
- ۹۰ تا ۱۸۰ روز
- بیش از ۶ ماه
**مبنای محاسبه:** «موعد بازدید» = آخرین بازدید معتبر + دورهٔ قالب (پیش‌فرض ۹ ماه).
برای مشتریِ بدون بازدید، مبنای مقایسه مقطع دوره است تا عدد بی‌معنا نشود.
یعنی «۳ ماه تأخیر» یعنی «۳ ماه از موعد بازدید گذشته»، نه «۳ ماه از خود بازدید».
**مبنای محاسبه:** «موعد بازدید» = آخرین بازدید معتبر + دورهٔ قالب. برای
مشتریِ بدون بازدید، مبنای مقایسه مقطع دوره است تا عدد بی‌معنا نشود.
یعنی «۳ ماه تأخیر» = «۳ ماه از موعد بازدید گذشته»، نه «۳ ماه از خود بازدید».
## ۳. مرتب‌سازی
> جمع این چهار عدد همیشه برابر تعداد ردیف‌های نمای جاری است (تست‌شده).
سه حالت اختیاری (پارامتر `sort` در URL، قابل بوکمارک):
## ۴. مرتب‌سازی (ستون‌ها و دراپ‌داون)
| مقدار | ترتیب | کاربرد |
|---|---|---|
| `oldest` (پیش‌فرض) | قدیمی‌ترین بازدید اول | اولویت کارشناس |
| `overdue` | بیشترین تأخیر اول | ببینیم چه کسی از همه دیرتر است |
| `newest` | جدیدترین بازدید اول | بررسی موارد تازه‌معوق |
**هر ۹ ستون** با کلیک روی سرستون مرتب می‌شوند؛ کلیک دوباره جهت را برمی‌گرداند:
ترتیب بر مبنای **تاریخ مؤثر بازدید از `Action`** است، نه ستون
`last_bazdid_dt` (که ممکن است کهنه باشد). برای گروهِ بدون بازدید، مبنای
ترتیب مقطع دوره است.
| ستون | مبنا |
|---|---|
| نام و نام خانوادگی | الفبایی |
| وضعیت قرار | بدون قرار ← تعویق ← بازدید ← پیگیری ← چندقراره |
| قرار دستی | تاریخ قرار (خالی‌ها آخر) |
| بازدید برنامه‌ریزی‌شده | تاریخ قرار (خالی‌ها آخر) |
| هماهنگی | تاریخ پیگیری (خالی‌ها آخر) |
| آخرین بازدید | تاریخ مؤثر بازدید از `Action` |
| تأخیر | تعداد روز تأخیر |
| تلفن | مقدار `phone1` |
| آدرس | الفبایی |
## ۴. جست‌وجو (تکمیل‌شده)
- سرستون فعال با فلش **▲/▼** و ستون‌های دیگر با **⇅** مشخص می‌شوند.
- **مقادیر خالی همیشه آخر می‌آیند**، مستقل از جهت — وگرنه مرتب‌سازی ستون‌های
قرار، فهرست را بی‌معنا می‌کرد.
- دراپ‌داون «مرتب‌سازی» چند گزینهٔ پرکاربرد را کوتاه می‌کند.
- مقادیر قدیمی `sort=oldest|newest|overdue` همچنان کار می‌کنند (سازگاری با
بوکمارک‌های قبلی).
پیش‌تر فقط جست‌وجو بر اساس **آدرس** ممکن بود. اکنون:
## ۵. جست‌وجو
| فیلد | جست‌وجو در |
|---|---|
@@ -430,8 +563,7 @@ python manage.py test tests
| آدرس | `address` |
| کد مشتری | `idCode` |
نما و مرتب‌سازی هنگام جست‌وجو **حفظ می‌شوند** و دکمهٔ «حذف جستجو» برای برگشت
به فهرست کامل هست.
نما، فیلتر تأخیر و مرتب‌سازی هنگام جست‌وجو **حفظ می‌شوند**.
## ۵. چیدمان ستون‌ها (بازطراحی)
@@ -506,23 +638,37 @@ python manage.py test tests
## ۱۰. پارامترهای URL
```
/panel/entezar/ نمای پیش‌فرض (بدون قرار، قدیمی‌ترین)
?view=all همهٔ معوق‌ها
?view=coordinated با قرار (اجتماع سه منبع)
?view=manual فقط قرار دستی
/panel/entezar/ پیش‌فرض: نیازمند پیگیری امروز
?view=all همهٔ عقب‌افتاده‌ها
?view=coordinated تعیین تکلیف شده (اجتماع سه منبع)
?view=today نیازمند پیگیری امروز
?view=today&fdate=1405/07/28 نیازمند پیگیری در یک روز دیگر (شمسی)
?view=manual فقط تعویق دستی
?view=scheduled فقط بازدید برنامه‌ریزی‌شده
?view=coordination فقط هماهنگی
?sort=overdue بیشترین تأخیر اول
?sort=newest جدیدترین بازدید اول
?view=coordination فقط پیگیری تلفنی
?delay=gt180 فقط بیش از ۶ ماه تأخیر
?sort=overdue&dir=desc بیشترین تأخیر اول
?sort=name&dir=asc الفبایی (صعودی)
?sort=manual&dir=desc تاریخ قرار دستی (نزولی)
?searchVal=رضایی&searchOn=name جست‌وجوی نام
?searchVal=0912&searchOn=phone جست‌وجوی تلفن
?view=all&sort=overdue&page=3 ترکیب‌پذیر
?view=all&delay=gt180&sort=name&dir=asc&page=3 همه با هم (ترکیب‌پذیر)
?view=today&missed=1 فقط پیگیری‌های عقب‌افتاده
```
مقدار نامعتبر `view` یا `sort` بی‌صدا به پیش‌فرض برمی‌گردد (هیچ خطای ۴۰۰/۵۰۰).
- **اندازهٔ صفحه = ۱۰۰ ردیف** (`ENTEZAR_PAGE_SIZE`)؛ صفحه‌بندی فقط از ردیف ۱۰۱ به
بعد فعال می‌شود.
3. پس از تاریخ قرار، مشتری **خودکار** در گروه A ظاهر می‌شود؛ نیازی به کار دستی نیست.
4. عدد «بدون قرار» در داشبورد همان لیست کاریِ فوری است.
- مقدار نامعتبر `view`/`sort`/`delay` بی‌صدا به پیش‌فرض برمی‌گردد (بدون ۴۰۰/۵۰۰).
- مقادیر قدیمی `sort=oldest|newest|overdue` همچنان معتبرند.
- همهٔ لینک‌های داخلی (کارت‌ها، بازه‌های تأخیر، سرستون‌ها، صفحه‌بندی) **بقیهٔ
فیلترها را حفظ می‌کنند**.
---
3. پس از تاریخ قرار، مشتری **خودکار** در گروه «تعیین تکلیف نشده» ظاهر می‌شود؛
نیازی به کار دستی نیست.
4. عدد «تعیین تکلیف نشده» در داشبورد همان لیست کاریِ فوری است.
+1 -1
View File
@@ -78,7 +78,7 @@ WSGI_APPLICATION = 'khatibi.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'data9/kt.sqlite3',
'NAME': BASE_DIR / 'data9/khatibi92669266_backups_db_backup_20261020_223011.sqlite3',
}
}
+113
View File
@@ -9,6 +9,8 @@
هیچ پاسخی نباید کد ۵۰۰ داشته باشد.
"""
import json
import re
from html.parser import HTMLParser
from django.test import TestCase
from django.urls import reverse
@@ -257,3 +259,114 @@ class ViewSmokeTests(TestCase):
f"داده‌اند: {failures}"
),
)
class _DivBalanceParser(HTMLParser):
"""شمارندهٔ عمق <div> با احترام به محتوای <script>/<style>.
نکته: HTMLParser محتوای script/style را متن خام می‌داند، پس `<div>` داخل
رشتهٔ جاوااسکریپت به‌اشتباه شمرده نمی‌شود — همان چیزی که شمارش متنی ساده
اشتباه می‌کند.
"""
def __init__(self):
super().__init__(convert_charrefs=True)
self.depth = 0
self.min_depth = 0
self.depth_at_body = None
def handle_starttag(self, tag, attrs):
if tag == 'div':
self.depth += 1
def handle_endtag(self, tag):
if tag == 'div':
self.depth -= 1
self.min_depth = min(self.min_depth, self.depth)
elif tag == 'body':
self.depth_at_body = self.depth
class BaseTemplateStructureTests(TestCase):
"""ساختار HTML قالب پایه.
چرا؟ قالب `base.html` یک `<div class="">` باز می‌کرد که هرگز بسته نمی‌شد.
مرورگر آن را در EOF خودکار می‌بست، پس هیچ خطایی دیده نمی‌شد؛ ولی همهٔ
صفحات HTML نامعتبر تولید می‌کردند و بازسازی DOM توسط جاوااسکریپت و
پارسرهای سخت‌گیر می‌توانست بشکند.
"""
URLS = ['/panel/', '/panel/customers/', '/panel/entezar/', '/panel/rezayat/',
'/panel/hesabdari/', '/panel/report/']
def setUp(self):
self.user = make_user('structureAdmin', superuser=True)
self.client.force_login(self.user)
def _parsed_pages(self):
"""صفحاتی که در محیط تست کد ۲۰۰ می‌دهند را پارس می‌کند.
برخی صفحات روی دیتابیس خالی خطای داخلی می‌دهند (باگ مستقل، پوشش‌داده‌شده
در تست دودی)؛ آن‌ها اینجا کنار گذاشته می‌شوند. برای اینکه تست بی‌اثر
نشود، تعداد صفحات بررسی‌شده assert می‌شود.
"""
parsed = []
for url in self.URLS:
response = self.client.get(url)
if response.status_code != 200:
continue
parser = _DivBalanceParser()
parser.feed(response.content.decode('utf-8', 'replace'))
parsed.append((url, parser))
return parsed
def test_every_page_has_balanced_divs(self):
"""در پایان سند، هیچ <div> بازی نباید باقی بماند."""
parsed = self._parsed_pages()
self.assertGreaterEqual(len(parsed), 5,
msg='صفحات کافی برای بررسی ساختار رندر نشدند')
for url, parser in parsed:
with self.subTest(url=url):
self.assertEqual(
parser.depth_at_body, 0,
msg='در %s هنگام </body> تعداد %d تگ <div> بسته‌نشده ماند'
% (url, parser.depth_at_body),
)
def test_no_stray_closing_div(self):
"""عمق هرگز نباید منفی شود؛ یعنی </div> اضافی هم وجود ندارد."""
parsed = self._parsed_pages()
self.assertGreaterEqual(len(parsed), 5)
for url, parser in parsed:
with self.subTest(url=url):
self.assertGreaterEqual(parser.min_depth, 0,
msg='در %s تگ </div> اضافی وجود دارد' % url)
def test_base_template_comments_are_single_line(self):
"""کامنت‌های {# ... #} باید تک‌خطی باشند.
موتور قالب جنگو (۳.۲) الگوی کامنت را بدون re.DOTALL می‌شناسد؛ کامنت
چندخطی حذف نمی‌شود و عیناً در HTML چاپ می‌شود — در همین اصلاح، یک
`<div class="">` داخل کامنت چندخطی، به‌عنوان تگ واقعی به خروجی اضافه
شده بود و تگ بستن را می‌بلعید.
"""
import io
import os
templates_root = os.path.join('admin_panel', 'templates')
offenders = []
for base, _dirs, files in os.walk(templates_root):
for name in files:
if not name.endswith('.html'):
continue
path = os.path.join(base, name)
text = io.open(path, encoding='utf-8', errors='replace').read()
for match in re.finditer(r'\{#', text):
tail = text[match.start():match.start() + 500]
line_end = tail.find('\n')
end = line_end if line_end > -1 else len(tail)
if tail.find('#}', 0, end) == -1:
offenders.append('%s:%d' % (
path, text[:match.start()].count('\n') + 1))
self.assertEqual(offenders, [],
msg='کامنت چندخطی در این قالب‌ها حذف نمی‌شود: %s' % offenders)
+543 -29
View File
@@ -24,6 +24,7 @@ from django.urls import reverse
from django.utils import timezone
from admin_panel.models import Action, Customer, PhoneNumber
from admin_panel.views.customers import ENTEZAR_PAGE_SIZE
from admin_panel.visit_policy import (active_deferral, as_aware, last_visit_dt,
last_visit_map, normalize_hold_until,
refresh_last_bazdid, set_visit_hold,
@@ -329,7 +330,9 @@ class EntezarScannerConsistencyTests(TestCase):
def _entezar_ids(self):
self.client.force_login(self.user)
response = self.client.get('/panel/entezar/')
# نمای صریح «تعیین تکلیف نشده» — همان چیزی که اسکنر پیامک با آن
# هم‌تراز است. (پیش‌فرض صفحه اکنون «نیازمند پیگیری امروز» است.)
response = self.client.get('/panel/entezar/?view=uncoordinated')
self.assertEqual(response.status_code, 200)
return {c.id for c in response.context['customers']}
@@ -607,7 +610,8 @@ class NextAppointmentTests(TestCase):
return customer
def _entezar_ids(self):
response = self.client.get('/panel/entezar/')
# نمای صریح «تعیین تکلیف نشده» (پیش‌فرض صفحه اکنون «پیگیری امروز» است).
response = self.client.get('/panel/entezar/?view=uncoordinated')
self.assertEqual(response.status_code, 200)
return {c.id for c in response.context['customers']}
@@ -783,13 +787,18 @@ class VisitHoldTests(TestCase):
customer.refresh_from_db()
return customer
def _entezar(self, view=''):
def _entezar(self, view='uncoordinated'):
"""پاسخ صفحه؛ پیش‌فرض این کلاس «تعیین تکلیف نشده» است.
(پیش‌فرض خودِ صفحه اکنون «نیازمند پیگیری امروز» است و در
``TodayFollowupTests`` جداگانه پوشش داده می‌شود.)
"""
url = '/panel/entezar/' + (f'?view={view}' if view else '')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
return response
def _ids(self, view=''):
def _ids(self, view='uncoordinated'):
return {c.id for c in self._entezar(view).context['customers']}
def _post_action(self, customer, **payload):
@@ -1141,8 +1150,11 @@ class VisitHoldTests(TestCase):
def test_invalid_view_falls_back_to_default(self):
a = self._customer('فقط الف')
self.assertEqual(self._ids('nonsense'), {a.pk})
self.assertEqual(self._entezar('nonsense').context['view'], 'uncoordinated')
# پیش‌فرض صفحه «نیازمند پیگیری امروز» است؛ مشتری بدون موعد در آن نیست،
# ولی در نمای «تعیین تکلیف نشده» دیده می‌شود.
self.assertEqual(self._entezar('nonsense').context['view'], 'today')
self.assertEqual(self._ids('nonsense'), set())
self.assertIn(a.pk, self._ids('uncoordinated'))
def test_card_number_matches_filtered_list(self):
"""عدد هر کارت باید با تعداد ردیف‌های همان فیلتر یکی باشد.
@@ -1216,15 +1228,54 @@ class VisitHoldTests(TestCase):
# سرِ نوار آمار + توضیح «عقب‌افتاده»
self.assertIn('ez-stats-title', html)
self.assertIn('ez-stats-hint', html)
# پنج توضیح برای کارت‌های بدون‌قرار/با‌قرار/… و ششمی برای هماهنگی
self.assertEqual(html.count('class="ez-desc"'), 6)
for phrase in ('هنوز وقت بازدید تعیین نشده',
'وقت بازدیدشان مشخص است',
'تعویق دستی از فرم هماهنگی',
'اقدام «بازدید» با تاریخ آینده',
'پیگیری به آینده موکول شده است'):
# چهار کارت: پیگیری امروز + سه کارت وضعیت
self.assertEqual(html.count('class="ez-desc"'), 4)
for phrase in ('موعد تماسشان امروز است',
'با هر وضعیت پیگیری',
'هنوز وقت بازدید تعیین نشده',
'وقت بعدی‌شان مشخص است'):
self.assertIn(phrase, html, f'توضیح «{phrase}» روی کارت‌ها نیست')
def test_card_titles_use_business_language(self):
"""نام کارت‌ها باید زبان کسب‌وکار باشد، نه زبان دیتابیس."""
html = self._entezar().content.decode()
self.assertIn('همهٔ مشتریان عقب‌افتاده', html)
self.assertIn('تعیین تکلیف نشده', html)
self.assertIn('تعیین تکلیف شده', html)
# نام‌های قدیمیِ گیج‌کننده نباید برگردند.
self.assertNotIn('کل عقب‌افتاده‌ها', html)
self.assertNotIn('قرار هماهنگی', html)
# «بازدید» به‌جای «بازدیدها» — عدد، تعداد مشتری است نه بازدید.
self.assertNotIn('کل بازدیدهای عقب افتاده', html)
def test_extra_cards_moved_into_type_filter(self):
"""سه کارت حذف‌شده باید فقط در دراپ‌داون «نوع قرار» باشند، نه روی کارت‌ها."""
html = self._entezar().content.decode()
# چهار کارت آمار: «نیازمند پیگیری امروز» + سه کارت وضعیت
self.assertEqual(html.count('class="ez-stat '), 4)
self.assertIn('نیازمند پیگیری امروز', html)
# هر سه فیلتر در دراپ‌داون موجودند
self.assertIn('name="view"', html)
for value in ('manual', 'scheduled', 'coordination'):
self.assertIn(f'value="{value}"', html)
def test_cards_have_no_breakdown_or_warning(self):
"""کارت‌ها باید ساده بمانند: هیچ تفکیک «تعویق/بازدید/پیگیری» رویشان نباشد."""
html = self._entezar().content.decode()
self.assertNotIn('ez-split', html)
self.assertNotIn('ez-alert', html)
cards = html.split('class="ez-filters"')[0]
# چهار عدد روی کارت‌ها (نه پنج/شش عدد قبلیِ تفکیک)
self.assertEqual(cards.count('class="ez-num'), 4)
# «تعویق» فقط در تفکیکِ حذف‌شده بود؛ نباید هیچ‌جای کارت‌ها بماند.
self.assertNotIn('تعویق', cards)
# هیچ شمارنده‌ای کنار «بازدید» یا «پیگیری» نباید باشد
# (الگوی قبلی: «۱۲ بازدید» / «۳۰۶ پیگیری»).
for word in ('بازدید', 'پیگیری'):
self.assertNotRegex(
cards, r'\d+\s*' + word,
msg=f'شمارندهٔ کنار «{word}» نباید روی کارت‌ها باشد')
# ---------- اسکنر پیامک هم قرار دستی را می‌بیند ----------
def test_scanner_skips_customer_with_manual_hold(self):
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
@@ -1476,7 +1527,11 @@ class EntezarLayoutTests(TestCase):
def test_header_cells_match_body_cells(self):
thead = self.html.split('<tbody>')[0]
first_row = self.html.split('<tbody>')[1].split('</tr>')[0]
self.assertEqual(thead.count('<th>'), first_row.count('<td'))
# سرستون‌ها اکنون لینک مرتب‌سازی دارند، پس کلاس می‌گیرند نه <th>ِ ساده.
# (``<thead`` هم با «<th» شروع می‌شود، پس با regex دقیق می‌شماریم.)
self.assertEqual(len(re.findall(r'<th[\s>]', thead)),
first_row.count('<td'))
self.assertEqual(len(re.findall(r'<th[\s>]', thead)), 9)
def test_no_fixed_min_width_on_columns(self):
# min-width روی ستون‌ها باعث اسکرول افقی و بی‌تناسبی می‌شد.
@@ -1524,33 +1579,40 @@ class EntezarPageControlsTests(TestCase):
finish_dt=at(-visit_days_ago))
return Customer.objects.get(pk=customer.pk)
def test_default_view_is_uncoordinated(self):
def test_default_view_is_today(self):
"""پیش‌فرض صفحه = «نیازمند پیگیری امروز»."""
response = self._get()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['view'], 'uncoordinated')
self.assertEqual(response.context['sort'], 'oldest')
self.assertEqual(response.context['view'], 'today')
self.assertTrue(response.context['is_followup_view'])
# ترتیب پیش‌فرض نمای پیگیری، زودترین موعد است (نه قدیمی‌ترین بازدید).
self.assertEqual(response.context['sort'], 'last_visit')
self.assertEqual(response.context['sort_dir'], 'asc')
def test_invalid_view_and_sort_fall_back(self):
response = self._get(view='nonsense', sort='bogus')
response = self._get(view='nonsense', sort='bogus', delay='bogus')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['view'], 'uncoordinated')
self.assertEqual(response.context['sort'], 'oldest')
self.assertEqual(response.context['view'], 'today')
self.assertEqual(response.context['sort'], 'last_visit')
self.assertEqual(response.context['sort_dir'], 'asc')
self.assertEqual(response.context['delay'], '')
def test_counts_partition_total(self):
self._overdue_customer('معوق ساده', visit_days_ago=400)
counts = self._get().context['counts']
counts = self._get(view='uncoordinated').context['counts']
self.assertEqual(counts['total'],
counts['uncoordinated'] + counts['coordinated'])
self.assertEqual(sum(counts['buckets'].values()), counts['uncoordinated'])
def test_coordinated_customer_hidden_in_default_view(self):
def test_coordinated_customer_hidden_in_uncoordinated_view(self):
customer = self._overdue_customer('با قرار', visit_days_ago=400)
# قرار فقط با هماهنگیِ فعال معتبر است (قاعدهٔ «ستون ≠ مرجع»).
make_visit(customer, action_dt=at(-1), action_type='hamahangi', status='waite')
set_visit_hold(customer, at(+10))
default_ids = [obj.id for obj in self._get().context['customers']]
self.assertNotIn(customer.id, default_ids)
uncoordinated_ids = [
obj.id for obj in self._get(view='uncoordinated').context['customers']]
self.assertNotIn(customer.id, uncoordinated_ids)
all_ids = [obj.id for obj in self._get(view='all').context['customers']]
self.assertIn(customer.id, all_ids)
@@ -1563,13 +1625,13 @@ class EntezarPageControlsTests(TestCase):
coordination = make_visit(customer, action_dt=at(-1),
action_type='hamahangi', status='waite')
set_visit_hold(customer, at(+10))
self.assertNotIn(customer.id,
[obj.id for obj in self._get().context['customers']])
self.assertNotIn(customer.id, [
obj.id for obj in self._get(view='uncoordinated').context['customers']])
coordination.status = 'cancell'
coordination.save(update_fields=['status'])
self.assertIn(customer.id,
[obj.id for obj in self._get().context['customers']])
self.assertIn(customer.id, [
obj.id for obj in self._get(view='uncoordinated').context['customers']])
def test_search_by_name_and_phone(self):
customer = self._overdue_customer('علی رضایی', visit_days_ago=400)
@@ -1578,11 +1640,86 @@ class EntezarPageControlsTests(TestCase):
Customer.objects.filter(pk=customer.pk).update(phone1='09121234567')
for field, value in (('name', 'رضایی'), ('phone', '09121234567')):
response = self._get(searchVal=value, searchOn=field)
response = self._get(view='uncoordinated', searchVal=value, searchOn=field)
self.assertEqual(response.status_code, 200)
self.assertIn(customer.id,
[obj.id for obj in response.context['customers']])
# ---------- فیلتر «میزان تأخیر» ----------
def test_delay_filter_selects_exact_range(self):
"""هر بازهٔ تأخیر فقط مشتریان همان بازه را برمی‌گرداند."""
# تأخیر = امروز − (آخرین بازدید + دورهٔ ۹ ماهه).
# بازدید ۲۸۰ روز پیش ⇒ تأخیر ~۶ روز (بازهٔ lt30)
# بازدید ۷۰۰ روز پیش ⇒ تأخیر ~۴۲۶ روز (بازهٔ gt180)
fresh = self._overdue_customer('تازه معوق', visit_days_ago=280)
old = self._overdue_customer('کهنه معوق', visit_days_ago=700)
ids_lt30 = [o.id for o in self._get(view='all', delay='lt30').context['customers']]
self.assertIn(fresh.id, ids_lt30)
self.assertNotIn(old.id, ids_lt30)
ids_gt180 = [o.id for o in self._get(view='all', delay='gt180').context['customers']]
self.assertNotIn(fresh.id, ids_gt180)
self.assertIn(old.id, ids_gt180)
def test_delay_buckets_sum_to_view_total(self):
self._overdue_customer('الف', visit_days_ago=280)
self._overdue_customer('ب', visit_days_ago=430)
for view in ('all', 'uncoordinated'):
counts = self._get(view=view).context['counts']
self.assertEqual(sum(counts['buckets'].values()), counts['view_total'],
f'جمع بازه‌های تأخیر با نمای {view} نمی‌خواند')
def test_delay_filter_does_not_change_card_counts(self):
"""کارت‌ها همیشه کل جامعه را نشان می‌دهند، نه نتیجهٔ فیلتر را."""
self._overdue_customer('الف', visit_days_ago=280)
base = self._get().context['counts']
filtered = self._get(delay='gt180').context['counts']
self.assertEqual(base['total'], filtered['total'])
self.assertEqual(base['uncoordinated'], filtered['uncoordinated'])
self.assertLessEqual(filtered['selected'], base['selected'])
# ---------- مرتب‌سازی ستون‌ها ----------
def test_every_column_is_sortable_both_directions(self):
self._overdue_customer('الف', visit_days_ago=400)
self._overdue_customer('ب', visit_days_ago=500)
for column in ('name', 'status', 'manual', 'scheduled', 'coordination',
'last_visit', 'overdue', 'phone', 'address'):
for direction in ('asc', 'desc'):
response = self._get(view='all', sort=column, dir=direction)
self.assertEqual(response.status_code, 200,
f'ستون {column}/{direction} خطا داد')
self.assertEqual(response.context['sort'], column)
self.assertEqual(response.context['sort_dir'], direction)
def test_explicit_direction_overrides_legacy_alias(self):
"""``sort=overdue&dir=asc`` باید صعودی بماند، نه به desc قدیمی بیفتد."""
self._overdue_customer('الف', visit_days_ago=400)
response = self._get(view='all', sort='overdue', dir='asc')
self.assertEqual(response.context['sort'], 'overdue')
self.assertEqual(response.context['sort_dir'], 'asc')
def test_name_sort_orders_alphabetically(self):
first = self._overdue_customer('آ ا', visit_days_ago=400)
second = self._overdue_customer('ی ی', visit_days_ago=400)
ids = [o.id for o in self._get(view='all', sort='name', dir='asc').context['customers']]
self.assertLess(ids.index(first.id), ids.index(second.id))
ids_desc = [o.id for o in self._get(view='all', sort='name', dir='desc').context['customers']]
self.assertLess(ids_desc.index(second.id), ids_desc.index(first.id))
def test_missing_appointment_dates_sort_last(self):
"""مشتریِ بدون قرار دستی باید در هر دو جهت، آخرِ فهرست بیاید."""
with_hold = self._overdue_customer('با قرار', visit_days_ago=400)
make_visit(with_hold, action_dt=at(-1), action_type='hamahangi', status='waite')
set_visit_hold(with_hold, at(+10))
without = self._overdue_customer('بدون قرار', visit_days_ago=400)
for direction in ('asc', 'desc'):
ids = [o.id for o in self._get(view='all', sort='manual',
dir=direction).context['customers']]
self.assertLess(ids.index(with_hold.id), ids.index(without.id),
f'در جهت {direction} مشتریِ بدون قرار اول آمد')
def test_sort_overdue_puts_biggest_delay_first(self):
oldest = self._overdue_customer('خیلی معوق', visit_days_ago=900)
newer = self._overdue_customer('کمتر معوق', visit_days_ago=300)
@@ -1592,3 +1729,380 @@ class EntezarPageControlsTests(TestCase):
ids_newest = [obj.id for obj in self._get(view='all', sort='newest').context['customers']]
self.assertLess(ids_newest.index(newer.id), ids_newest.index(oldest.id))
# ---------- سرستون‌ها و حفظ فیلترها ----------
def test_sortable_headers_render_with_arrows(self):
html = self._get(view='all', sort='name', dir='desc').content.decode()
self.assertEqual(html.count('class="ez-th '), 9)
# فقط یک سرستون فعال است (کلاس دقیق، نه تکرار در CSS).
self.assertEqual(html.count('class="ez-th is-sorted"'), 1)
self.assertIn('ez-arrow', html)
self.assertIn('▼', html) # جهت نزولیِ فعال
self.assertIn('⇅', html) # ستون‌های غیرفعال
def test_header_link_toggles_direction(self):
response = self._get(view='all', sort='name', dir='asc')
header = {h['key']: h for h in response.context['sort_header']}['name']
self.assertTrue(header['active'])
self.assertEqual(header['dir'], 'asc')
# لینک همان ستون باید جهت را برگرداند.
self.assertIn('dir=desc', header['link'])
def test_filter_links_preserve_other_filters(self):
response = self._get(view='all', delay='gt180', sort='name', dir='asc')
ctx = response.context
for link in ctx['view_links'].values():
self.assertIn('delay=gt180', link)
self.assertIn('sort=name', link)
for link in ctx['delay_links'].values():
self.assertIn('sort=name', link)
for link in ctx['sort_links'].values():
self.assertIn('delay=gt180', link)
def test_clear_link_resets_everything(self):
response = self._get(view='all', delay='gt180', sort='name', dir='asc',
searchVal='x', searchOn='name')
self.assertEqual(response.context['clear_link'], '?view=today')
self.assertTrue(response.context['has_filters'])
def test_no_filters_means_clean_state(self):
self.assertFalse(self._get().context['has_filters'])
def test_pagination_keeps_filters(self):
# یک ردیف بیشتر از اندازهٔ صفحه تا صفحهٔ دوم ساخته شود.
for index in range(ENTEZAR_PAGE_SIZE + 1):
self._overdue_customer(f'مشتری شماره {index}', visit_days_ago=700)
response = self._get(view='all', delay='gt180', sort='name', dir='asc')
self.assertTrue(response.context['page_next'])
self.assertIn('delay=gt180', response.context['page_next'])
self.assertIn('page=2', response.context['page_next'])
self.assertIn('sort=name', response.context['page_next'])
self.assertEqual(response.context['page_prev'], '')
class TodayFollowupTests(TestCase):
"""نمای «نیازمند پیگیری امروز» در صفحهٔ انتظار.
قواعد قفل‌شده:
* فقط موعدهای **همان روز** (پیش‌فرض: امروز) نمایش داده می‌شوند.
* مشتریانی که برایشان «بازدید» گذاشته شده **هرگز** در این فهرست نمی‌آیند.
* با تقویم شمسی می‌توان روزهای دیگر (گذشته/آینده) را دید.
* عدد کارت با تعداد فهرست امروز برابر است و با تغییر تاریخ عوض نمی‌شود.
"""
URL = '/panel/entezar/'
TODAY = timezone.localdate()
def setUp(self):
self.user = make_user('followupViewer', superuser=True)
self.client.force_login(self.user)
# ---------- helpers ----------
def _customer(self, name, *, active=True):
customer = make_customer(name)
Customer.objects.filter(pk=customer.pk).update(
installDate=self.TODAY - timedelta(days=400), is_active=active,
)
customer.refresh_from_db()
return customer
def _coordination(self, customer, day_offset=0):
"""اقدام هماهنگی با موعد پیگیری در روز مشخص (نسبت به امروز)."""
noon = timezone.make_aware(
datetime.datetime.combine(
self.TODAY + timedelta(days=day_offset), datetime.time(12, 0)
),
timezone.get_current_timezone(),
)
return make_visit(customer, action_dt=noon, action_type='hamahangi',
status='finish')
def _visit(self, customer, day_offset=0):
"""اقدام بازدیدِ در انتظار (قرار بازدید)."""
noon = timezone.make_aware(
datetime.datetime.combine(
self.TODAY + timedelta(days=day_offset), datetime.time(10, 0)
),
timezone.get_current_timezone(),
)
return make_visit(customer, action_dt=noon, action_type='bazdid',
status='waite')
def _jalali(self, day_offset=0):
from persiantools.jdatetime import JalaliDate
return JalaliDate(self.TODAY + timedelta(days=day_offset)).strftime('%Y/%m/%d')
def _ids(self, query=''):
response = self.client.get(self.URL + '?view=today' + query)
self.assertEqual(response.status_code, 200)
return {obj.id for obj in response.context['customers']}
# ---------- هستهٔ رفتار ----------
def test_followup_today_appears(self):
customer = self._customer('پیگیری امروز')
self._coordination(customer, 0)
self.assertIn(customer.id, self._ids())
def test_followup_other_days_not_in_today(self):
yesterday = self._customer('دیروز')
tomorrow = self._customer('فردا')
self._coordination(yesterday, -1)
self._coordination(tomorrow, +1)
ids = self._ids()
self.assertNotIn(yesterday.id, ids)
self.assertNotIn(tomorrow.id, ids)
def test_calendar_navigates_to_other_days(self):
yesterday = self._customer('دیروز')
tomorrow = self._customer('فردا')
self._coordination(yesterday, -1)
self._coordination(tomorrow, +1)
past = self._ids('&fdate=' + self._jalali(-1))
future = self._ids('&fdate=' + self._jalali(+1))
self.assertIn(yesterday.id, past)
self.assertNotIn(tomorrow.id, past)
self.assertIn(tomorrow.id, future)
self.assertNotIn(yesterday.id, future)
def test_invalid_followup_date_falls_back_to_today(self):
customer = self._customer('امروز')
self._coordination(customer, 0)
self.assertIn(customer.id, self._ids('&fdate=نامعتبر'))
def test_customer_without_any_followup_never_appears(self):
customer = self._customer('بدون موعد')
self.assertNotIn(customer.id, self._ids())
# ---------- قاعدهٔ قطعی: «بازدید» هرگز در این فهرست نمی‌آید ----------
def test_visit_today_excludes_customer(self):
"""بازدید برای همان روز ⇒ مشتری نباید در فهرست پیگیری باشد."""
customer = self._customer('بازدید امروز')
self._coordination(customer, 0)
self.assertIn(customer.id, self._ids())
self._visit(customer, 0)
self.assertNotIn(
customer.id, self._ids(),
msg='مشتریِ دارای بازدیدِ امروز نباید در فهرست پیگیری بیاید',
)
def test_visit_in_future_excludes_customer(self):
"""بازدیدی که برای آینده گذاشته شده هم مشتری را از فهرست بیرون می‌برد."""
customer = self._customer('بازدید آینده')
self._coordination(customer, 0)
self._visit(customer, +5)
self.assertNotIn(customer.id, self._ids())
def test_visit_in_past_does_not_exclude(self):
"""بازدیدِ کهنه (backlog بسته‌نشده) قرار فعال نیست ⇒ نباید پنهان کند."""
customer = self._customer('بازدید کهنه')
self._coordination(customer, 0)
self._visit(customer, -30)
self.assertIn(customer.id, self._ids())
def test_scheduled_view_still_works(self):
"""قاعدهٔ حذف نباید نمای «بازدید برنامه‌ریزی‌شده» را خالی کند."""
customer = self._customer('بازدید برنامه')
self._visit(customer, +3)
response = self.client.get(self.URL + '?view=scheduled')
self.assertIn(customer.id, {o.id for o in response.context['customers']})
# ---------- قرار دستی روی ستون مشتری ----------
def test_manual_hold_today_appears(self):
customer = self._customer('قرار دستی امروز')
self._coordination(customer, -3)
# قرار دستی روی پایان همان روزِ امروز
noon_today = timezone.make_aware(
datetime.datetime.combine(self.TODAY, datetime.time(12, 0)),
timezone.get_current_timezone(),
)
set_visit_hold(customer, noon_today)
self.assertIn(customer.id, self._ids())
# ---------- شمارنده‌ها و نماها ----------
def test_card_count_matches_list(self):
first = self._customer('الف')
second = self._customer('ب')
self._coordination(first, 0)
self._coordination(second, 0)
response = self.client.get(self.URL)
self.assertEqual(response.context['counts']['followup_today'],
response.context['counts']['followup_today'])
listed = self.client.get(self.URL + '?view=today').context
self.assertEqual(listed['counts']['followup_today'],
listed['customers'].paginator.count)
def test_card_count_stays_today_when_browsing_other_day(self):
customer = self._customer('فردا')
self._coordination(customer, +1)
today_ctx = self.client.get(self.URL).context
past_ctx = self.client.get(
self.URL + '?view=today&fdate=' + self._jalali(-1)).context
self.assertEqual(today_ctx['counts']['followup_today'],
past_ctx['counts']['followup_today'])
def test_default_view_is_today(self):
response = self.client.get(self.URL)
self.assertEqual(response.context['view'], 'today')
self.assertTrue(response.context['is_followup_view'])
def test_inactive_and_cancelled_customers_excluded(self):
inactive = self._customer('غیرفعال', active=False)
cancelled = self._customer('لغو بازدید')
self._coordination(inactive, 0)
self._coordination(cancelled, 0)
Customer.objects.filter(pk=cancelled.pk).update(laghv_bazdid=True)
ids = self._ids()
self.assertNotIn(inactive.id, ids)
self.assertNotIn(cancelled.id, ids)
def test_search_inside_followup_view(self):
wanted = self._customer('علی رضایی')
other = self._customer('محمد حسینی')
self._coordination(wanted, 0)
self._coordination(other, 0)
ids = self._ids('&searchVal=رضایی&searchOn=name')
self.assertIn(wanted.id, ids)
self.assertNotIn(other.id, ids)
def test_ordered_by_earliest_followup_time(self):
late = self._customer('دیرتر')
early = self._customer('زودتر')
tz = timezone.get_current_timezone()
for customer, hour in ((late, 18), (early, 8)):
moment = timezone.make_aware(
datetime.datetime.combine(self.TODAY, datetime.time(hour, 0)), tz)
make_visit(customer, action_dt=moment, action_type='hamahangi',
status='finish')
response = self.client.get(self.URL + '?view=today')
order = [obj.id for obj in response.context['customers']]
self.assertLess(order.index(early.id), order.index(late.id))
def test_date_picker_and_nav_rendered(self):
customer = self._customer('نمایش تقویم')
self._coordination(customer, 0)
html = self.client.get(self.URL + '?view=today').content.decode()
self.assertIn('followupDatePicker', html)
self.assertIn('pDatepicker', html)
self.assertIn('ez-datebar', html)
self.assertIn('name="fdate"', html)
# پیش‌فرض صفحه = نمای پیگیری ⇒ نوار تاریخ باید در صفحهٔ بدون پارامتر هم باشد.
html_default = self.client.get(self.URL).content.decode()
self.assertIn('class="ez-datebar"', html_default)
self.assertIn('followupDatePicker', html_default)
# در نمای دیگر، نوار تاریخ رندر نشود.
html_other = self.client.get(self.URL + '?view=uncoordinated').content.decode()
self.assertNotIn('class="ez-datebar"', html_other)
self.assertNotIn('followupDatePicker', html_other)
def test_browsing_other_day_shows_banner(self):
customer = self._customer('روز دیگر')
self._coordination(customer, -1)
html = self.client.get(
self.URL + '?view=today&fdate=' + self._jalali(-1)).content.decode()
self.assertIn('بازگشت به امروز', html)
# ---------- ستون «تأخیر» فقط در نمای پیگیری مخفی ----------
def test_overdue_column_hidden_only_in_followup_view(self):
customer = self._customer('ستون تأخیر')
self._coordination(customer, 0)
followup_html = self.client.get(self.URL + '?view=today').content.decode()
self.assertNotIn('data-label="تأخیر"', followup_html)
self.assertNotIn('>تأخیر<', followup_html)
other_html = self.client.get(self.URL + '?view=uncoordinated').content.decode()
self.assertIn('data-label="تأخیر"', other_html)
def test_column_widths_sum_to_hundred_in_both_modes(self):
customer = self._customer('ردیف نمونه')
self._coordination(customer, 0)
for query in ('?view=today', '?view=all'):
html = self.client.get(self.URL + query).content.decode()
widths = [float(w) for w in re.findall(
r'<col style="width: (\d+(?:\.\d+)?)%">', html)]
self.assertAlmostEqual(sum(widths), 100.0, places=1,
msg=f'جمع عرض ستون‌ها در {query} برابر ۱۰۰ نیست')
# تعداد <th> با تعداد <td> هر ردیف باید یکی باشد.
thead = html.split('<tbody>')[0]
first_row = html.split('<tbody>')[1].split('</tr>')[0]
self.assertEqual(len(re.findall(r'<th[\s>]', thead)),
first_row.count('<td'))
# ---------- صفحه‌بندی ۱۰۰تایی ----------
def test_page_size_is_one_hundred(self):
for index in range(105):
self._customer(f'مشتری شماره {index}')
page = self.client.get(self.URL + '?view=all').context['customers']
self.assertEqual(len(page.object_list), 100)
self.assertEqual(page.paginator.num_pages, 2)
# ---------- هشدار پیگیری‌های عقب‌افتاده ----------
def test_overdue_warning_appears_for_past_due(self):
late = self._customer('موعد گذشته')
self._coordination(late, -3)
ctx = self.client.get(self.URL).context
self.assertEqual(ctx['counts']['followup_overdue'], 1)
html = self.client.get(self.URL).content.decode()
self.assertIn('class="ez-overdue-alert"', html)
self.assertIn('مورد نیازمند پیگیری از روزهای قبل باقی مانده', html)
def test_overdue_warning_absent_when_all_on_time(self):
today_only = self._customer('فقط امروز')
self._coordination(today_only, 0)
future = self._customer('فردا')
self._coordination(future, +2)
ctx = self.client.get(self.URL).context
self.assertEqual(ctx['counts']['followup_overdue'], 0)
self.assertNotIn('class="ez-overdue-alert"',
self.client.get(self.URL).content.decode())
def test_overdue_warning_matches_missed_list(self):
"""عدد هشدار باید دقیقاً برابر تعداد ردیف‌های فهرست «عقب‌افتاده‌ها» باشد."""
for offset in (-1, -5, -30, 0):
customer = self._customer(f'موعد {offset}')
self._coordination(customer, offset)
ctx = self.client.get(self.URL).context
missed = self.client.get(self.URL + '?view=today&missed=1').context
self.assertEqual(missed['customers'].paginator.count,
ctx['counts']['followup_overdue'])
self.assertEqual(missed['counts']['followup_overdue'],
ctx['counts']['followup_overdue'])
def test_newer_coordination_clears_overdue(self):
customer = self._customer('تمدید شده')
self._coordination(customer, -10)
self.assertEqual(self.client.get(self.URL).context['counts']['followup_overdue'], 1)
# کارشناس دوباره تماس گرفته و موعد جدید گذاشته ⇒ دیگر عقب‌افتاده نیست.
self._coordination(customer, +5)
self.assertEqual(self.client.get(self.URL).context['counts']['followup_overdue'], 0)
def test_scheduled_visit_clears_overdue(self):
customer = self._customer('بازدید گرفته')
self._coordination(customer, -10)
self._visit(customer, +1)
self.assertEqual(self.client.get(self.URL).context['counts']['followup_overdue'], 0)
def test_missed_mode_renders_chip_and_back_link(self):
customer = self._customer('عقب‌افتاده')
self._coordination(customer, -4)
html = self.client.get(self.URL + '?view=today&missed=1').content.decode()
self.assertIn('class="ez-overdue-mode"', html)
self.assertNotIn('<a class="ez-overdue-alert"', html)
self.assertIn('بازگشت به امروز', html)
def test_overdue_warning_only_in_followup_view(self):
customer = self._customer('عقب‌افتاده دیگر')
self._coordination(customer, -4)
html = self.client.get(self.URL + '?view=uncoordinated').content.decode()
self.assertNotIn('class="ez-overdue-alert"', html)