improve bazdid 4
This commit is contained in:
1 parent
f86d060e4d
commit
8fd881e0ea
10 files changed
+1752
-320
No files matched your search
+14
-5
@@ -1,11 +1,11 @@
|
|||||||
Project Root: khatibi
|
Project Root: khatibi
|
||||||
Generated At: 2026-10-20T17:07:57+00:00
|
Generated At: 2026-10-21T10:54:58+00:00
|
||||||
Python Files: 187
|
Python Files: 188
|
||||||
Django Files: 12
|
Django Files: 12
|
||||||
JavaScript/TypeScript Files: 14
|
JavaScript/TypeScript Files: 14
|
||||||
HTML Files: 58
|
HTML Files: 58
|
||||||
Total Files: 746
|
Total Files: 754
|
||||||
Total Directories: 78
|
Total Directories: 79
|
||||||
|
|
||||||
khatibi
|
khatibi
|
||||||
├── .cursor
|
├── .cursor
|
||||||
@@ -129,6 +129,7 @@ khatibi
|
|||||||
│ │ ├── 0076_alter_smstemplate_text_help.py
|
│ │ ├── 0076_alter_smstemplate_text_help.py
|
||||||
│ │ ├── 0077_alter_smstemplate_text_balance_help.py
|
│ │ ├── 0077_alter_smstemplate_text_balance_help.py
|
||||||
│ │ ├── 0078_action_visit_covering_index.py
|
│ │ ├── 0078_action_visit_covering_index.py
|
||||||
|
│ │ ├── 0079_customer_visit_hold.py
|
||||||
│ │ └── __init__.py
|
│ │ └── __init__.py
|
||||||
│ ├── models
|
│ ├── models
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
@@ -323,10 +324,18 @@ khatibi
|
|||||||
│ └── visit_policy.py
|
│ └── visit_policy.py
|
||||||
├── data9
|
├── data9
|
||||||
│ ├── backup-20261020-180934.sqlite3
|
│ ├── backup-20261020-180934.sqlite3
|
||||||
|
│ ├── backup-20261020-213406.sqlite3
|
||||||
│ ├── db2 (2).sqlite32
|
│ ├── db2 (2).sqlite32
|
||||||
│ ├── db2.sqlite32
|
│ ├── db2.sqlite32
|
||||||
│ ├── db222.sqlite3
|
│ ├── 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
|
├── graphify-out
|
||||||
│ ├── 2026-10-09
|
│ ├── 2026-10-09
|
||||||
│ │ ├── .graphify_analysis.json
|
│ │ ├── .graphify_analysis.json
|
||||||
|
|||||||
@@ -239,6 +239,8 @@
|
|||||||
|
|
||||||
{% block bodyPage %}
|
{% block bodyPage %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{# بستن <div class=""> که بالاتر باز شده بود؛ پیشتر بسته نمیشد و HTML نامعتبر میشد. #}
|
||||||
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -164,6 +164,8 @@
|
|||||||
|
|
||||||
{% block bodyPage %}
|
{% block bodyPage %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{# بستن <div class=""> که بالاتر باز شده بود (همان باگ base.html). #}
|
||||||
|
</div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
@media (min-width: 1024px) {
|
@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 {
|
.ez-stat {
|
||||||
display: flex; flex-direction: column; gap: .18rem;
|
display: flex; flex-direction: column; gap: .18rem;
|
||||||
@@ -41,6 +41,7 @@
|
|||||||
.ez-stat.warning { border-top-color: #d97706; }
|
.ez-stat.warning { border-top-color: #d97706; }
|
||||||
.ez-stat.link { border-top-color: #0284c7; }
|
.ez-stat.link { border-top-color: #0284c7; }
|
||||||
.ez-stat.violet { border-top-color: #7c3aed; }
|
.ez-stat.violet { border-top-color: #7c3aed; }
|
||||||
|
.ez-stat.followup { border-top-color: #0e7490; }
|
||||||
|
|
||||||
.ez-stat-top {
|
.ez-stat-top {
|
||||||
display: flex; align-items: baseline; justify-content: space-between; gap: .4rem;
|
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-legend b { font-variant-numeric: tabular-nums; color: #334155; }
|
||||||
|
|
||||||
/* ---------- ابزارکها ---------- */
|
/* ---------- نوار تاریخ (نمای «نیازمند پیگیری») ---------- */
|
||||||
.ez-toolbar {
|
.ez-datebar {
|
||||||
display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin: 0 0 .75rem;
|
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-datebar .button { margin: 0 !important; }
|
||||||
.ez-seg {
|
.ez-dateform { margin: 0; }
|
||||||
display: inline-flex; border: 1px solid #e2e8f0; border-radius: .5rem; overflow: hidden;
|
.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 {
|
.ez-datenote {
|
||||||
padding: .3rem .6rem; font-size: .72rem; color: #475569; text-decoration: none;
|
flex: 1 1 100%; font-size: .7rem; color: #64748b; line-height: 1.7;
|
||||||
background: #fff; border-inline-end: 1px solid #e2e8f0; white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.ez-seg a:last-child { border-inline-end: 0; }
|
.ez-datenote b { color: #334155; }
|
||||||
.ez-seg a:hover { background: #f1f5f9; }
|
@media (min-width: 700px) {
|
||||||
.ez-seg a.is-active { background: #33b5d3; color: #fff; font-weight: 700; }
|
.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; }
|
.ez-table { width: 100%; margin: 0; font-size: .82rem; border-collapse: collapse; }
|
||||||
@@ -196,134 +260,231 @@
|
|||||||
<div class="ez-wrap">
|
<div class="ez-wrap">
|
||||||
<div class="columns m-0">
|
<div class="columns m-0">
|
||||||
<div class="column is-12 p-0">
|
<div class="column is-12 p-0">
|
||||||
{# ===== نوار آمار: هر عدد لینک فیلتر ===== #}
|
{# ===== نوار آمار: چهار کارت، هر عدد لینک فیلتر ===== #}
|
||||||
<div class="ez-stats-head">
|
<div class="ez-stats-head">
|
||||||
<p class="ez-stats-title">وضعیت مشتریانِ عقبافتاده</p>
|
<p class="ez-stats-title">وضعیت پیگیری مشتریان</p>
|
||||||
<p class="ez-stats-hint">
|
<p class="ez-stats-hint">
|
||||||
«عقبافتاده» یعنی از آخرین بازدید
|
«عقبافتاده» یعنی از آخرین بازدید
|
||||||
<b>بیش از {{ counts.period_months }} ماه</b> گذشته باشد.
|
<b>بیش از {{ counts.period_months }} ماه</b> گذشته باشد.
|
||||||
روی هر کارت بزنید تا فقط همان گروه نمایش داده شود؛
|
روی هر کارت بزنید تا فقط همان گروه نمایش داده شود؛
|
||||||
<b>کل = بدون قرار + با قرار</b>.
|
<b>کل = تعیینتکلیفنشده + تعیینتکلیفشده</b>.
|
||||||
سه کارتِ آخر سه نوع قرارِ جدا هستند؛ یک مشتری میتواند بیش از
|
|
||||||
یکی داشته باشد، پس جمع آنها ممکن است از «با قرار» بیشتر شود.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ez-stats">
|
<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 %}"
|
<a href="{{ view_links.today }}"
|
||||||
title="همهٔ مشتریان عقبافتاده، با هر وضعیت قرار">
|
class="ez-stat followup {% if view == followup_view %}is-active{% endif %}"
|
||||||
|
title="مشتریانی که موعد پیگیری/تماسشان امروز است و برایشان بازدید برنامهریزی نشده">
|
||||||
<span class="ez-stat-top">
|
<span class="ez-stat-top">
|
||||||
<span class="ez-lbl">کل عقبافتادهها</span>
|
<span class="ez-lbl">نیازمند پیگیری امروز</span>
|
||||||
<span class="ez-num">{{ counts.total }}</span>
|
<span class="ez-num has-text-info">{{ counts.followup_today }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="ez-desc">همه، فارغ از داشتن یا نداشتن قرار</span>
|
<span class="ez-desc">موعد تماسشان امروز است — بازدید ندارند</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="?view=uncoordinated&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
<a href="{{ view_links.all }}"
|
||||||
class="ez-stat danger {% if view == 'uncoordinated' %}is-active{% endif %}"
|
class="ez-stat total {% if view == 'all' %}is-active{% endif %}"
|
||||||
title="مشتریانی که هنوز هیچ قرار بعدی برایشان ثبت نشده است">
|
title="همهٔ مشتریانی که بازدیدشان عقب افتاده، با هر وضعیت پیگیری">
|
||||||
<span class="ez-stat-top">
|
<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 class="ez-num has-text-danger">{{ counts.uncoordinated }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="ez-desc">هنوز وقت بازدید تعیین نشده — نیاز به تماس</span>
|
<span class="ez-desc">هنوز وقت بازدید تعیین نشده — نیاز به تماس</span>
|
||||||
</a>
|
</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 %}"
|
class="ez-stat success {% if view == 'coordinated' %}is-active{% endif %}"
|
||||||
title="مشتریانی که وقت بازدید بعدی برایشان مشخص شده است">
|
title="مشتریانی که وقت بازدید یا پیگیریِ بعدی برایشان تعیین شده است">
|
||||||
<span class="ez-stat-top">
|
<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 class="ez-num has-text-success">{{ counts.coordinated }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="ez-desc">وقت بازدیدشان مشخص است (هر دو نوع قرار)</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>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# تفکیک مدت تأخیر در نمای جاری #}
|
{# ===== نوار تاریخ در نمای «نیازمند پیگیری» ===== #}
|
||||||
<div class="ez-legend">
|
{% if is_followup_view %}
|
||||||
<span>میزان تأخیر در همین نما:</span>
|
<div class="ez-datebar">
|
||||||
<span><b>{{ counts.buckets.lt30 }}</b> تا ۳۰ روز</span>
|
<a href="{{ followup_nav.prev }}" class="button is-small is-light"
|
||||||
<span><b>{{ counts.buckets.30to90 }}</b> ۳۰ تا ۹۰ روز</span>
|
title="روز قبل">‹ روز قبل</a>
|
||||||
<span><b>{{ counts.buckets.90to180 }}</b> ۹۰ تا ۱۸۰ روز</span>
|
|
||||||
<span><b>{{ counts.buckets.gt180 }}</b> بیش از ۶ ماه</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# ===== ابزارکها: جستجو و مرتبسازی ===== #}
|
<form method="get" class="ez-dateform">
|
||||||
<div class="ez-toolbar">
|
<input type="hidden" name="view" value="{{ followup_view }}">
|
||||||
<a href="#" id="openSearchBox" class="button is-info is-outlined is-rounded is-small">
|
{% if searchVal %}<input type="hidden" name="searchVal" value="{{ searchVal }}">{% endif %}
|
||||||
جستجو{% if searchVal %}: «{{ searchVal }}»{% endif %}
|
{% if searchVal %}<input type="hidden" name="searchOn" value="{{ searchOn }}">{% endif %}
|
||||||
</a>
|
<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>
|
<a href="{{ followup_nav.next }}" class="button is-small is-light"
|
||||||
<span class="ez-seg">
|
title="روز بعد">روز بعد ›</a>
|
||||||
<a href="?view={{ view }}&sort=oldest{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
|
||||||
class="{% if sort == 'oldest' %}is-active{% endif %}">قدیمیترین</a>
|
{% if not followup_nav.is_today %}
|
||||||
<a href="?view={{ view }}&sort=overdue{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
<a href="{{ followup_nav.today }}" class="button is-success is-light is-small">
|
||||||
class="{% if sort == 'overdue' %}is-active{% endif %}">بیشترین تأخیر</a>
|
بازگشت به امروز
|
||||||
<a href="?view={{ view }}&sort=newest{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
</a>
|
||||||
class="{% if sort == 'newest' %}is-active{% endif %}">جدیدترین</a>
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="ez-datenote">
|
||||||
|
{% if followup_nav.is_today %}
|
||||||
|
فقط موعدهای <b>امروز</b> نمایش داده میشوند.
|
||||||
|
{% else %}
|
||||||
|
نمایش موعدهای <b>{{ followup_date_jalali }}</b> (نه امروز).
|
||||||
|
{% endif %}
|
||||||
|
مشتریانی که برایشان <b>بازدید</b> گذاشته شده در این فهرست نمیآیند.
|
||||||
</span>
|
</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 %}
|
{% endif %}
|
||||||
</div>
|
</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">
|
<div class="table-container">
|
||||||
<table class="table ez-table">
|
<table class="table ez-table">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style="width: 21%">
|
{% for col in table_columns %}
|
||||||
<col style="width: 12%">
|
<col style="width: {{ col.width }}%">
|
||||||
<col style="width: 10%">
|
{% endfor %}
|
||||||
<col style="width: 10%">
|
|
||||||
<col style="width: 10%">
|
|
||||||
<col style="width: 9%">
|
|
||||||
<col style="width: 8%">
|
|
||||||
<col style="width: 9%">
|
|
||||||
<col style="width: 11%">
|
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>نام و نام خانوادگی</th>
|
{% for col in sort_header %}
|
||||||
<th>وضعیت قرار</th>
|
<th class="ez-th {% if col.active %}is-sorted{% endif %}">
|
||||||
<th>قرار دستی</th>
|
<a href="{{ col.link }}" title="مرتبسازی بر اساس {{ col.label }}">
|
||||||
<th>بازدید برنامهریزیشده</th>
|
{{ col.label }}<span class="ez-arrow">{% if col.active %}{% if col.dir == 'asc' %}▲{% else %}▼{% endif %}{% else %}⇅{% endif %}</span>
|
||||||
<th>هماهنگی</th>
|
</a>
|
||||||
<th>آخرین بازدید</th>
|
</th>
|
||||||
<th>تأخیر</th>
|
{% endfor %}
|
||||||
<th>تلفن</th>
|
|
||||||
<th>آدرس</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -364,6 +525,7 @@
|
|||||||
<td data-label="آخرین بازدید" class="ez-num ez-nowrap lastView">
|
<td data-label="آخرین بازدید" class="ez-num ez-nowrap lastView">
|
||||||
{% if appt.last_visit %}{{ appt.last_visit|toJalali }}{% else %}<span class="ez-muted">بدون بازدید</span>{% endif %}
|
{% if appt.last_visit %}{{ appt.last_visit|toJalali }}{% else %}<span class="ez-muted">بدون بازدید</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
{% if 'overdue' not in hidden_columns %}
|
||||||
<td data-label="تأخیر" class="ez-nowrap">
|
<td data-label="تأخیر" class="ez-nowrap">
|
||||||
{% if appt.overdue_days >= 180 %}
|
{% if appt.overdue_days >= 180 %}
|
||||||
<span class="ez-late lv3">{{ appt.overdue_days }} روز</span>
|
<span class="ez-late lv3">{{ appt.overdue_days }} روز</span>
|
||||||
@@ -375,6 +537,7 @@
|
|||||||
<span class="ez-late lv0">{{ appt.overdue_days }} روز</span>
|
<span class="ez-late lv0">{{ appt.overdue_days }} روز</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
<td data-label="تلفن" class="ez-nowrap">
|
<td data-label="تلفن" class="ez-nowrap">
|
||||||
@@ -397,8 +560,14 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="9" class="has-text-centered py-5">
|
<td colspan="{{ column_count }}" class="has-text-centered py-5">
|
||||||
<span class="has-text-grey">موردی در این نما پیدا نشد.</span>
|
<span class="has-text-grey">
|
||||||
|
{% if is_followup_view %}
|
||||||
|
برای تاریخ {{ followup_date_jalali }} موردی برای پیگیری نیست.
|
||||||
|
{% else %}
|
||||||
|
موردی در این نما پیدا نشد.
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -409,7 +578,7 @@
|
|||||||
{# ===== صفحهبندی ===== #}
|
{# ===== صفحهبندی ===== #}
|
||||||
<div class="is-flex is-align-items-center is-justify-content-space-between mt-3 mb-5">
|
<div class="is-flex is-align-items-center is-justify-content-space-between mt-3 mb-5">
|
||||||
{% if customers.has_previous %}
|
{% 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 %}
|
{% else %}<span></span>{% endif %}
|
||||||
|
|
||||||
<span class="is-size-7 has-text-grey">
|
<span class="is-size-7 has-text-grey">
|
||||||
@@ -418,60 +587,57 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{% if customers.has_next %}
|
{% 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 %}
|
{% else %}<span></span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% endblock %}
|
||||||
{% block jsEnd %}
|
{% block jsEnd %}
|
||||||
<script>
|
<script>
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
$("#openSearchBox").click(function(e) {
|
// تغییر هر دراپداون ⇒ ارسال خودکار فرم فیلترها.
|
||||||
e.preventDefault();
|
$(".ez-auto-submit").on('change', function () {
|
||||||
$(".SearchBox").addClass("is-active");
|
$(this).closest('form').trigger('submit');
|
||||||
});
|
});
|
||||||
$(".closeSearch").click(function() {
|
|
||||||
$(".SearchBox").removeClass("is-active");
|
// مقدار «مرتبسازی» با علامتِ جهت ترکیب میشود (مثلاً 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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+381
-76
@@ -19,19 +19,47 @@ from ..sms.phones import (normalize_primary_values,
|
|||||||
from ..sms.triggers import notify_payment_thanks, notify_welcome
|
from ..sms.triggers import notify_payment_thanks, notify_welcome
|
||||||
from ..sms.rules import customer_toggle_map
|
from ..sms.rules import customer_toggle_map
|
||||||
from ..visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED,
|
from ..visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED,
|
||||||
appointment_sources_map, last_visit_map, visit_due_at,
|
appointment_sources_map, as_aware,
|
||||||
visit_due_date, visit_period_months)
|
followups_on_date, last_visit_map, overdue_followups,
|
||||||
|
visit_due_at, visit_due_date, visit_period_months)
|
||||||
|
|
||||||
# نماهای صفحهٔ «انتظار» (فیلتر ?view=). پیشفرض = فقط «بدون قرار» تا رفتار
|
# نماهای صفحهٔ «انتظار» (فیلتر ?view=).
|
||||||
# قبلی صفحه برای کاربران حفظ شود.
|
# پیشفرض = «نیازمند پیگیری امروز»: فهرست کاریِ روزانهٔ کارشناس (موعد تماسهای
|
||||||
ENTEZAR_VIEWS = ('uncoordinated', 'manual', 'scheduled', 'coordination',
|
# امروز). سایر نماها با کارتها/دراپداون «نوع قرار» در دسترساند.
|
||||||
|
# ``today`` یک نمای **مستقل** است: پایهٔ آن «عقبافتاده بودن» نیست — مشتری
|
||||||
|
# سالمی هم که با او قرار تماس گذاشته شده باید در این فهرست دیده شود.
|
||||||
|
ENTEZAR_VIEWS = ('today', 'uncoordinated', 'manual', 'scheduled', 'coordination',
|
||||||
'coordinated', 'all')
|
'coordinated', 'all')
|
||||||
ENTEZAR_DEFAULT_VIEW = 'uncoordinated'
|
ENTEZAR_DEFAULT_VIEW = 'today'
|
||||||
# حالتهای مرتبسازی (?sort=):
|
# نمای «نیازمند پیگیری» — برای بررسی «آیا این نما فعال است؟» در قالب/ویو.
|
||||||
# oldest ⇒ قدیمیترین بازدید اول (پیشفرض/اولویت کارشناس)
|
ENTEZAR_FOLLOWUP_VIEW = 'today'
|
||||||
# newest ⇒ جدیدترین بازدید اول
|
|
||||||
# overdue ⇒ بیشترین تأخیر اول
|
# تعداد ردیف در هر صفحه. فهرست کاری روزانه با یک نگاه مرور میشود، پس صفحهبندی
|
||||||
ENTEZAR_SORTS = ('oldest', 'newest', 'overdue')
|
# دیرتر فعال شود.
|
||||||
|
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
|
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()
|
view = (request.GET.get('view') or ENTEZAR_DEFAULT_VIEW).strip().lower()
|
||||||
if view not in ENTEZAR_VIEWS:
|
if view not in ENTEZAR_VIEWS:
|
||||||
view = ENTEZAR_DEFAULT_VIEW # مقدار نامعتبر ⇒ پیشفرض، بدون خطا
|
view = ENTEZAR_DEFAULT_VIEW # مقدار نامعتبر ⇒ پیشفرض، بدون خطا
|
||||||
sort = (request.GET.get('sort') or 'oldest').strip().lower()
|
delay = (request.GET.get('delay') or '').strip().lower()
|
||||||
if sort not in ENTEZAR_SORTS:
|
if delay not in ENTEZAR_DELAYS:
|
||||||
sort = 'oldest'
|
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
|
searchStatus = True
|
||||||
full_path = request.get_full_path()
|
full_path = request.get_full_path()
|
||||||
|
|
||||||
@@ -370,32 +415,47 @@ def entezar(request):
|
|||||||
# دوره فقط یک بار خوانده میشود (جلوگیری از N+1 در حلقهٔ محاسبهٔ تأخیر).
|
# دوره فقط یک بار خوانده میشود (جلوگیری از N+1 در حلقهٔ محاسبهٔ تأخیر).
|
||||||
period_months = visit_period_months()
|
period_months = visit_period_months()
|
||||||
# مرز تاریخ به وقت محلی (نه UTC) تا صفحه و اسکنر پیامک یک جواب بدهند.
|
# مرز تاریخ به وقت محلی (نه 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)
|
visits = last_visit_map(now=now)
|
||||||
sources = appointment_sources_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(
|
customers_objj = Customer.objects.filter(
|
||||||
laghv_bazdid=False, name_family__isnull=False,
|
laghv_bazdid=False, name_family__isnull=False,
|
||||||
installDate__lte=due_cut_date,
|
installDate__lte=due_cut_date,
|
||||||
)
|
)
|
||||||
# جستوجو: نام، آدرس، تلفن (همه شمارهها) و کد شناسایی.
|
customers_objj = _apply_search(customers_objj)
|
||||||
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
|
|
||||||
|
|
||||||
# مشتریان عقبافتاده: بدون بازدید معتبر، یا بازدید معتبرشان پیش از مقطع دوره.
|
# مشتریان عقبافتاده: بدون بازدید معتبر، یا بازدید معتبرشان پیش از مقطع دوره.
|
||||||
stale_ids = [
|
stale_ids = [
|
||||||
@@ -431,12 +491,79 @@ def entezar(request):
|
|||||||
'coordinated': coordinated_ids,
|
'coordinated': coordinated_ids,
|
||||||
'all': stale_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]
|
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 = {}
|
overdue_days = {}
|
||||||
for customer_id in selected:
|
for customer_id in selected:
|
||||||
anchor = visits.get(customer_id)
|
anchor = visits.get(customer_id)
|
||||||
@@ -448,42 +575,7 @@ def entezar(request):
|
|||||||
base = due_cut_date
|
base = due_cut_date
|
||||||
overdue_days[customer_id] = max((today - base).days, 0)
|
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}
|
buckets = {'lt30': 0, '30to90': 0, '90to180': 0, 'gt180': 0}
|
||||||
for days in overdue_days.values():
|
for days in overdue_days.values():
|
||||||
if days < 30:
|
if days < 30:
|
||||||
@@ -495,17 +587,110 @@ def entezar(request):
|
|||||||
else:
|
else:
|
||||||
buckets['gt180'] += 1
|
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 = {
|
counts = {
|
||||||
'total': len(stale_ids),
|
'total': len(stale_ids),
|
||||||
'uncoordinated': len(selected_map['uncoordinated']),
|
'uncoordinated': len(selected_map['uncoordinated']),
|
||||||
# عددِ هر کارت دقیقاً همان چیزی است که با کلیک روی آن دیده میشود؛
|
# عددِ هر کارت دقیقاً همان چیزی است که با کلیک روی آن دیده میشود.
|
||||||
# چون یک مشتری میتواند چند منبع داشته باشد، جمع کارتها ممکن است
|
# یک مشتری میتواند چند منبع داشته باشد، پس جمع سه منبع میتواند از
|
||||||
# از «با قرار» بیشتر شود. در راهنمای بالای صفحه توضیح داده میشود.
|
# «تعیین تکلیف شده» بیشتر شود؛ جزئیات در دراپداون «نوع قرار» است.
|
||||||
'manual': len(selected_map['manual']),
|
'manual': len(selected_map['manual']),
|
||||||
'scheduled': len(selected_map['scheduled']),
|
'scheduled': len(selected_map['scheduled']),
|
||||||
'coordination': len(selected_map['coordination']),
|
'coordination': len(selected_map['coordination']),
|
||||||
'coordinated': len(selected_map['coordinated']),
|
'coordinated': len(selected_map['coordinated']),
|
||||||
|
# کارت «نیازمند پیگیری امروز» — همیشه عدد امروز را نشان میدهد،
|
||||||
|
# مستقل از تاریخی که کاربر در تقویم انتخاب کرده است.
|
||||||
|
'followup_today': today_followup_count,
|
||||||
|
# پیگیریهای موعدگذشته که هنوز تعیین تکلیف نشدهاند (هشدار قرمز).
|
||||||
|
'followup_overdue': overdue_followup_count,
|
||||||
'selected': len(selected),
|
'selected': len(selected),
|
||||||
|
'view_total': len(selected_map[view]),
|
||||||
'buckets': buckets,
|
'buckets': buckets,
|
||||||
# برای متن راهنمای بالای صفحه («بیش از N ماه از بازدید گذشته»).
|
# برای متن راهنمای بالای صفحه («بیش از N ماه از بازدید گذشته»).
|
||||||
'period_months': period_months,
|
'period_months': period_months,
|
||||||
@@ -513,20 +698,118 @@ def entezar(request):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
entezar_bazdid = CacheTable.objects.get(key='9mahbazdid')
|
entezar_bazdid = CacheTable.objects.get(key='9mahbazdid')
|
||||||
# کارت داشبورد = «کارِ فوری» = بدون قرار.
|
# کارت داشبورد = «کارِ فوری» = تعیین تکلیف نشده.
|
||||||
entezar_bazdid.value = counts['uncoordinated']
|
entezar_bazdid.value = counts['uncoordinated']
|
||||||
entezar_bazdid.save()
|
entezar_bazdid.save()
|
||||||
except CacheTable.DoesNotExist:
|
except CacheTable.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
paginator = Paginator(customers_objj, 20)
|
paginator = Paginator(customers_objj, ENTEZAR_PAGE_SIZE)
|
||||||
page_number = request.GET.get('page', None)
|
page_number = request.GET.get('page', None)
|
||||||
customerHa = paginator.get_page(page_number)
|
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 = {}
|
appointment_rows = {}
|
||||||
for customer in customerHa.object_list:
|
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,
|
flags = [key for key in (SOURCE_MANUAL, SOURCE_SCHEDULED,
|
||||||
SOURCE_COORDINATION) if key in entry]
|
SOURCE_COORDINATION) if key in entry]
|
||||||
appointment_rows[customer.id] = {
|
appointment_rows[customer.id] = {
|
||||||
@@ -537,14 +820,36 @@ def entezar(request):
|
|||||||
'last_visit': visits.get(customer.id),
|
'last_visit': visits.get(customer.id),
|
||||||
# تعداد منابع (۰ تا ۳) — قالب بر اساس آن برچسب وضعیت را میسازد.
|
# تعداد منابع (۰ تا ۳) — قالب بر اساس آن برچسب وضعیت را میسازد.
|
||||||
'source_count': len(flags),
|
'source_count': len(flags),
|
||||||
|
# تاریخ پیگیری این نمای خاص (برای ستون اختصاصی نمای «امروز»).
|
||||||
|
'followup_at': (min(followup_entry.values())
|
||||||
|
if followup_entry else None),
|
||||||
}
|
}
|
||||||
|
|
||||||
return render(request, 'admin_panel/entezar.html', {
|
return render(request, 'admin_panel/entezar.html', {
|
||||||
'customers': customerHa, 'searchStatus': searchStatus, 'full_path': full_path,
|
'customers': customerHa, 'searchStatus': searchStatus, 'full_path': full_path,
|
||||||
'searchVal': searchVal, 'searchOn': searchOn,
|
'searchVal': searchVal, 'searchOn': searchOn,
|
||||||
'view': view, 'counts': counts, 'appointment_rows': appointment_rows,
|
'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,
|
'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
@@ -402,33 +402,24 @@ def coordination_holds_map(*, now=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def manual_holds_map(*, now=None):
|
def _valid_manual_holds(holds):
|
||||||
"""قرارهای دستی (ستون ``visit_hold_until``) — **با اعتبارسنجی**.
|
"""اعتبارسنجی قرارهای دستی — قاعدهٔ قطعی «ستون، مرجع نیست».
|
||||||
|
|
||||||
قاعدهٔ قطعی: ستون بهتنهایی معتبر نیست. یک قرار دستی فقط وقتی اثر دارد که:
|
ورودی ``{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:
|
if not holds:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
customer_ids = list(holds)
|
customer_ids = list(holds)
|
||||||
|
|
||||||
# مرجع اعتبار: وجود یک اقدام «هماهنگیِ فعال» برای همان مشتری (نه لزوماً با
|
# مرجع اعتبار: وجود یک اقدام «هماهنگیِ فعال» برای همان مشتری (نه لزوماً با
|
||||||
@@ -469,6 +460,190 @@ def manual_holds_map(*, now=None):
|
|||||||
return valid
|
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):
|
def appointment_sources_map(*, now=None):
|
||||||
"""نگاشت ``customer_id → {منبع: تاریخ}`` برای هر سه منبع (فقط آینده).
|
"""نگاشت ``customer_id → {منبع: تاریخ}`` برای هر سه منبع (فقط آینده).
|
||||||
|
|
||||||
|
|||||||
+187
-41
@@ -273,14 +273,15 @@ visit_hold_until = max(مقدار فعلی، تاریخ جدید)
|
|||||||
|
|
||||||
| URL | نمایش | شامل |
|
| URL | نمایش | شامل |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/panel/entezar/` (بدون پارامتر) | **پیشفرض = بدون قرار** (رفتار قبلی) | بدون هیچ منبع |
|
| `/panel/entezar/` (بدون پارامتر) | **پیشفرض = نیازمند پیگیری امروز** | موعد تماسهای امروز |
|
||||||
|
| `/panel/entezar/?view=today` | نیازمند پیگیری امروز (یا تاریخ `fdate`) | موعد تماسهای آن روز |
|
||||||
| `?view=uncoordinated` | بدون قرار | بدون هیچ منبع |
|
| `?view=uncoordinated` | بدون قرار | بدون هیچ منبع |
|
||||||
| `?view=manual` | قرار دستی | هر مشتری با `manual` |
|
| `?view=manual` | قرار دستی | هر مشتری با `manual` |
|
||||||
| `?view=scheduled` | بازدید برنامهریزیشده | هر مشتری با `scheduled` |
|
| `?view=scheduled` | بازدید برنامهریزیشده | هر مشتری با `scheduled` |
|
||||||
| `?view=coordination` | هماهنگی | هر مشتری با `coordination` |
|
| `?view=coordination` | هماهنگی | هر مشتری با `coordination` |
|
||||||
| `?view=coordinated` | همهٔ دارای قرار | اجتماع هر سه منبع |
|
| `?view=coordinated` | همهٔ دارای قرار | اجتماع هر سه منبع |
|
||||||
| `?view=all` | همهٔ معوقها | همه |
|
| `?view=all` | همهٔ معوقها | همه |
|
||||||
| مقدار نامعتبر | برگشت به پیشفرض، **بدون خطا** | بدون هیچ منبع |
|
| مقدار نامعتبر | برگشت به پیشفرض (`today`)، **بدون خطا** | موعد تماسهای امروز |
|
||||||
|
|
||||||
چون یک مشتری میتواند چند منبع داشته باشد، در چند فیلتر دیده میشود؛ اما در
|
چون یک مشتری میتواند چند منبع داشته باشد، در چند فیلتر دیده میشود؛ اما در
|
||||||
`?view=coordinated` فقط **یکبار** شمرده میشود. به همین دلیل جمع سه کارت منبع
|
`?view=coordinated` فقط **یکبار** شمرده میشود. به همین دلیل جمع سه کارت منبع
|
||||||
@@ -380,48 +381,180 @@ python manage.py test tests
|
|||||||
|
|
||||||
## ۱. نوار آمار (بالای صفحه)
|
## ۱. نوار آمار (بالای صفحه)
|
||||||
|
|
||||||
پنج کارت که هر کدام هم **عدد** را نشان میدهد و هم **لینک فیلتر** است:
|
چهار کارت. عنوانها با **زبان کسبوکار** انتخاب شدهاند، نه زبان دیتابیس. هر کارت
|
||||||
|
هم عدد را نشان میدهد و هم **لینک فیلتر** است:
|
||||||
|
|
||||||
| کارت | چه چیزی را نشان میدهد |
|
| کارت | معنا | توضیح زیر عدد |
|
||||||
|
|---|---|---|
|
||||||
|
| **نیازمند پیگیری امروز** | موعد تماسشان **امروز** است و بازدید ندارند — فهرست کارِ امروز | موعد تماسشان امروز است — بازدید ندارند |
|
||||||
|
| **همهٔ مشتریان عقبافتاده** | همه، با هر وضعیت پیگیری | با هر وضعیت پیگیری |
|
||||||
|
| **تعیین تکلیف نشده** | هنوز هیچ وقت بازدید/پیگیری ندارند | هنوز وقت بازدید تعیین نشده — نیاز به تماس |
|
||||||
|
| **تعیین تکلیف شده** | وقت بعدیشان مشخص است | وقت بعدیشان مشخص است |
|
||||||
|
|
||||||
|
> **چرا «مشتری» و نه «بازدید»؟** عددِ کارت دوم تعداد **مشتری** است، نه تعداد
|
||||||
|
> بازدید. یک مشتری میتواند دهها بازدید گذشته داشته باشد ولی فقط **یک بار**
|
||||||
|
> در این فهرست شمرده شود.
|
||||||
|
|
||||||
|
### تفکیک نوع قرار — در دراپداون، نه روی کارت
|
||||||
|
|
||||||
|
کارتها **عمداً ساده** نگه داشته شدهاند: فقط عنوان، عدد و یک توضیح. جزئیات سه
|
||||||
|
نوع قرار (تعویق / بازدید / پیگیری) روی کارت چاپ **نمیشود**؛ چون:
|
||||||
|
|
||||||
|
- شش عدد روی کارتها باعث سردرگمی میشد (بازخورد کاربران).
|
||||||
|
- ارتفاع کارتها نامساوی و چیدمان موبایل بههم میریخت.
|
||||||
|
- همان اطلاعات از دراپداون **«نوع قرار»** در نوار فیلتر در دسترس است.
|
||||||
|
|
||||||
|
> جمع سه منبع میتواند از عدد «تعیین تکلیف شده» بیشتر باشد، چون یک مشتری
|
||||||
|
> میتواند چند منبع قرار داشته باشد؛ در `?view=coordinated` هر مشتری فقط
|
||||||
|
> **یکبار** شمرده میشود.
|
||||||
|
|
||||||
|
## ۱.۱ نمای «نیازمند پیگیری امروز» (`?view=today`) — **پیشفرض صفحه**
|
||||||
|
|
||||||
|
> **پیشفرض:** با باز کردن `/panel/entezar/` (بدون پارامتر)، همین نما نمایش داده
|
||||||
|
> میشود؛ کاربر بلافاصله فهرست کارِ امروز را میبیند.
|
||||||
|
|
||||||
|
فهرست کاریِ روزانهٔ کارشناس: **با چه کسانی امروز باید تماس بگیریم؟**
|
||||||
|
|
||||||
|
### این فهرست از کجا میآید؟
|
||||||
|
|
||||||
|
دو منبع، هر دو با شرط «موعد = همان روز»:
|
||||||
|
|
||||||
|
| منبع | تعریف |
|
||||||
|---|---|
|
|---|---|
|
||||||
| کل معوق | همهٔ مشتریان عقبافتاده (با قرار و بدون قرار) |
|
| **هماهنگی** | اقدام `hamahangi` با `status` در (`waite`/`finish`) و `actionDateTime` (وگرنه `finishDateTime`) در همان روز |
|
||||||
| بدون قرار | کسانی که هیچ قراری ندارند — **کار عملیاتی امروز** |
|
| **قرار دستی** | `Customer.visit_hold_until` که تاریخش همان روز است (با همان اعتبارسنجی «ستون، مرجع نیست») |
|
||||||
| با قرار | قرار هماهنگی یا بازدید برنامهریزیشده دارند |
|
|
||||||
| قرار هماهنگی | قرار دستی ثبتشده روی مشتری |
|
|
||||||
| بازدید برنامهریزیشده | بازدید/هماهنگیِ در جریان از نوع `Action` |
|
|
||||||
|
|
||||||
کارت فعال با حاشیهٔ آبی مشخص میشود.
|
### قاعدهٔ قطعی: «بازدید» اینجا نمیآید
|
||||||
|
|
||||||
## ۲. تفکیک مدت تأخیر
|
> اگر برای مشتری **قرار بازدید** (`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` |
|
| آدرس | `address` |
|
||||||
| کد مشتری | `idCode` |
|
| کد مشتری | `idCode` |
|
||||||
|
|
||||||
نما و مرتبسازی هنگام جستوجو **حفظ میشوند** و دکمهٔ «حذف جستجو» برای برگشت
|
نما، فیلتر تأخیر و مرتبسازی هنگام جستوجو **حفظ میشوند**.
|
||||||
به فهرست کامل هست.
|
|
||||||
|
|
||||||
## ۵. چیدمان ستونها (بازطراحی)
|
## ۵. چیدمان ستونها (بازطراحی)
|
||||||
|
|
||||||
@@ -506,23 +638,37 @@ python manage.py test tests
|
|||||||
## ۱۰. پارامترهای URL
|
## ۱۰. پارامترهای URL
|
||||||
|
|
||||||
```
|
```
|
||||||
/panel/entezar/ نمای پیشفرض (بدون قرار، قدیمیترین)
|
/panel/entezar/ پیشفرض: نیازمند پیگیری امروز
|
||||||
?view=all همهٔ معوقها
|
?view=all همهٔ عقبافتادهها
|
||||||
?view=coordinated با قرار (اجتماع سه منبع)
|
?view=coordinated تعیین تکلیف شده (اجتماع سه منبع)
|
||||||
?view=manual فقط قرار دستی
|
?view=today نیازمند پیگیری امروز
|
||||||
|
?view=today&fdate=1405/07/28 نیازمند پیگیری در یک روز دیگر (شمسی)
|
||||||
|
?view=manual فقط تعویق دستی
|
||||||
?view=scheduled فقط بازدید برنامهریزیشده
|
?view=scheduled فقط بازدید برنامهریزیشده
|
||||||
?view=coordination فقط هماهنگی
|
?view=coordination فقط پیگیری تلفنی
|
||||||
?sort=overdue بیشترین تأخیر اول
|
?delay=gt180 فقط بیش از ۶ ماه تأخیر
|
||||||
?sort=newest جدیدترین بازدید اول
|
?sort=overdue&dir=desc بیشترین تأخیر اول
|
||||||
|
?sort=name&dir=asc الفبایی (صعودی)
|
||||||
|
?sort=manual&dir=desc تاریخ قرار دستی (نزولی)
|
||||||
?searchVal=رضایی&searchOn=name جستوجوی نام
|
?searchVal=رضایی&searchOn=name جستوجوی نام
|
||||||
?searchVal=0912&searchOn=phone جستوجوی تلفن
|
?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 ظاهر میشود؛ نیازی به کار دستی نیست.
|
- مقدار نامعتبر `view`/`sort`/`delay` بیصدا به پیشفرض برمیگردد (بدون ۴۰۰/۵۰۰).
|
||||||
4. عدد «بدون قرار» در داشبورد همان لیست کاریِ فوری است.
|
- مقادیر قدیمی `sort=oldest|newest|overdue` همچنان معتبرند.
|
||||||
|
- همهٔ لینکهای داخلی (کارتها، بازههای تأخیر، سرستونها، صفحهبندی) **بقیهٔ
|
||||||
|
فیلترها را حفظ میکنند**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
3. پس از تاریخ قرار، مشتری **خودکار** در گروه «تعیین تکلیف نشده» ظاهر میشود؛
|
||||||
|
نیازی به کار دستی نیست.
|
||||||
|
4. عدد «تعیین تکلیف نشده» در داشبورد همان لیست کاریِ فوری است.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
+1
-1
@@ -78,7 +78,7 @@ WSGI_APPLICATION = 'khatibi.wsgi.application'
|
|||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
'NAME': BASE_DIR / 'data9/kt.sqlite3',
|
'NAME': BASE_DIR / 'data9/khatibi92669266_backups_db_backup_20261020_223011.sqlite3',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
هیچ پاسخی نباید کد ۵۰۰ داشته باشد.
|
هیچ پاسخی نباید کد ۵۰۰ داشته باشد.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -257,3 +259,114 @@ class ViewSmokeTests(TestCase):
|
|||||||
f"دادهاند: {failures}"
|
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
@@ -24,6 +24,7 @@ from django.urls import reverse
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from admin_panel.models import Action, Customer, PhoneNumber
|
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,
|
from admin_panel.visit_policy import (active_deferral, as_aware, last_visit_dt,
|
||||||
last_visit_map, normalize_hold_until,
|
last_visit_map, normalize_hold_until,
|
||||||
refresh_last_bazdid, set_visit_hold,
|
refresh_last_bazdid, set_visit_hold,
|
||||||
@@ -329,7 +330,9 @@ class EntezarScannerConsistencyTests(TestCase):
|
|||||||
|
|
||||||
def _entezar_ids(self):
|
def _entezar_ids(self):
|
||||||
self.client.force_login(self.user)
|
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)
|
self.assertEqual(response.status_code, 200)
|
||||||
return {c.id for c in response.context['customers']}
|
return {c.id for c in response.context['customers']}
|
||||||
|
|
||||||
@@ -607,7 +610,8 @@ class NextAppointmentTests(TestCase):
|
|||||||
return customer
|
return customer
|
||||||
|
|
||||||
def _entezar_ids(self):
|
def _entezar_ids(self):
|
||||||
response = self.client.get('/panel/entezar/')
|
# نمای صریح «تعیین تکلیف نشده» (پیشفرض صفحه اکنون «پیگیری امروز» است).
|
||||||
|
response = self.client.get('/panel/entezar/?view=uncoordinated')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
return {c.id for c in response.context['customers']}
|
return {c.id for c in response.context['customers']}
|
||||||
|
|
||||||
@@ -783,13 +787,18 @@ class VisitHoldTests(TestCase):
|
|||||||
customer.refresh_from_db()
|
customer.refresh_from_db()
|
||||||
return customer
|
return customer
|
||||||
|
|
||||||
def _entezar(self, view=''):
|
def _entezar(self, view='uncoordinated'):
|
||||||
|
"""پاسخ صفحه؛ پیشفرض این کلاس «تعیین تکلیف نشده» است.
|
||||||
|
|
||||||
|
(پیشفرض خودِ صفحه اکنون «نیازمند پیگیری امروز» است و در
|
||||||
|
``TodayFollowupTests`` جداگانه پوشش داده میشود.)
|
||||||
|
"""
|
||||||
url = '/panel/entezar/' + (f'?view={view}' if view else '')
|
url = '/panel/entezar/' + (f'?view={view}' if view else '')
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def _ids(self, view=''):
|
def _ids(self, view='uncoordinated'):
|
||||||
return {c.id for c in self._entezar(view).context['customers']}
|
return {c.id for c in self._entezar(view).context['customers']}
|
||||||
|
|
||||||
def _post_action(self, customer, **payload):
|
def _post_action(self, customer, **payload):
|
||||||
@@ -1141,8 +1150,11 @@ class VisitHoldTests(TestCase):
|
|||||||
|
|
||||||
def test_invalid_view_falls_back_to_default(self):
|
def test_invalid_view_falls_back_to_default(self):
|
||||||
a = self._customer('فقط الف')
|
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):
|
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-title', html)
|
||||||
self.assertIn('ez-stats-hint', html)
|
self.assertIn('ez-stats-hint', html)
|
||||||
# پنج توضیح برای کارتهای بدونقرار/باقرار/… و ششمی برای هماهنگی
|
# چهار کارت: پیگیری امروز + سه کارت وضعیت
|
||||||
self.assertEqual(html.count('class="ez-desc"'), 6)
|
self.assertEqual(html.count('class="ez-desc"'), 4)
|
||||||
for phrase in ('هنوز وقت بازدید تعیین نشده',
|
for phrase in ('موعد تماسشان امروز است',
|
||||||
'وقت بازدیدشان مشخص است',
|
'با هر وضعیت پیگیری',
|
||||||
'تعویق دستی از فرم هماهنگی',
|
'هنوز وقت بازدید تعیین نشده',
|
||||||
'اقدام «بازدید» با تاریخ آینده',
|
'وقت بعدیشان مشخص است'):
|
||||||
'پیگیری به آینده موکول شده است'):
|
|
||||||
self.assertIn(phrase, html, f'توضیح «{phrase}» روی کارتها نیست')
|
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):
|
def test_scanner_skips_customer_with_manual_hold(self):
|
||||||
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
|
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
|
||||||
@@ -1476,7 +1527,11 @@ class EntezarLayoutTests(TestCase):
|
|||||||
def test_header_cells_match_body_cells(self):
|
def test_header_cells_match_body_cells(self):
|
||||||
thead = self.html.split('<tbody>')[0]
|
thead = self.html.split('<tbody>')[0]
|
||||||
first_row = self.html.split('<tbody>')[1].split('</tr>')[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):
|
def test_no_fixed_min_width_on_columns(self):
|
||||||
# min-width روی ستونها باعث اسکرول افقی و بیتناسبی میشد.
|
# min-width روی ستونها باعث اسکرول افقی و بیتناسبی میشد.
|
||||||
@@ -1524,33 +1579,40 @@ class EntezarPageControlsTests(TestCase):
|
|||||||
finish_dt=at(-visit_days_ago))
|
finish_dt=at(-visit_days_ago))
|
||||||
return Customer.objects.get(pk=customer.pk)
|
return Customer.objects.get(pk=customer.pk)
|
||||||
|
|
||||||
def test_default_view_is_uncoordinated(self):
|
def test_default_view_is_today(self):
|
||||||
|
"""پیشفرض صفحه = «نیازمند پیگیری امروز»."""
|
||||||
response = self._get()
|
response = self._get()
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.context['view'], 'uncoordinated')
|
self.assertEqual(response.context['view'], 'today')
|
||||||
self.assertEqual(response.context['sort'], 'oldest')
|
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):
|
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.status_code, 200)
|
||||||
self.assertEqual(response.context['view'], 'uncoordinated')
|
self.assertEqual(response.context['view'], 'today')
|
||||||
self.assertEqual(response.context['sort'], 'oldest')
|
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):
|
def test_counts_partition_total(self):
|
||||||
self._overdue_customer('معوق ساده', visit_days_ago=400)
|
self._overdue_customer('معوق ساده', visit_days_ago=400)
|
||||||
counts = self._get().context['counts']
|
counts = self._get(view='uncoordinated').context['counts']
|
||||||
self.assertEqual(counts['total'],
|
self.assertEqual(counts['total'],
|
||||||
counts['uncoordinated'] + counts['coordinated'])
|
counts['uncoordinated'] + counts['coordinated'])
|
||||||
self.assertEqual(sum(counts['buckets'].values()), counts['uncoordinated'])
|
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)
|
customer = self._overdue_customer('با قرار', visit_days_ago=400)
|
||||||
# قرار فقط با هماهنگیِ فعال معتبر است (قاعدهٔ «ستون ≠ مرجع»).
|
# قرار فقط با هماهنگیِ فعال معتبر است (قاعدهٔ «ستون ≠ مرجع»).
|
||||||
make_visit(customer, action_dt=at(-1), action_type='hamahangi', status='waite')
|
make_visit(customer, action_dt=at(-1), action_type='hamahangi', status='waite')
|
||||||
set_visit_hold(customer, at(+10))
|
set_visit_hold(customer, at(+10))
|
||||||
|
|
||||||
default_ids = [obj.id for obj in self._get().context['customers']]
|
uncoordinated_ids = [
|
||||||
self.assertNotIn(customer.id, default_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']]
|
all_ids = [obj.id for obj in self._get(view='all').context['customers']]
|
||||||
self.assertIn(customer.id, all_ids)
|
self.assertIn(customer.id, all_ids)
|
||||||
@@ -1563,13 +1625,13 @@ class EntezarPageControlsTests(TestCase):
|
|||||||
coordination = make_visit(customer, action_dt=at(-1),
|
coordination = make_visit(customer, action_dt=at(-1),
|
||||||
action_type='hamahangi', status='waite')
|
action_type='hamahangi', status='waite')
|
||||||
set_visit_hold(customer, at(+10))
|
set_visit_hold(customer, at(+10))
|
||||||
self.assertNotIn(customer.id,
|
self.assertNotIn(customer.id, [
|
||||||
[obj.id for obj in self._get().context['customers']])
|
obj.id for obj in self._get(view='uncoordinated').context['customers']])
|
||||||
|
|
||||||
coordination.status = 'cancell'
|
coordination.status = 'cancell'
|
||||||
coordination.save(update_fields=['status'])
|
coordination.save(update_fields=['status'])
|
||||||
self.assertIn(customer.id,
|
self.assertIn(customer.id, [
|
||||||
[obj.id for obj in self._get().context['customers']])
|
obj.id for obj in self._get(view='uncoordinated').context['customers']])
|
||||||
|
|
||||||
def test_search_by_name_and_phone(self):
|
def test_search_by_name_and_phone(self):
|
||||||
customer = self._overdue_customer('علی رضایی', visit_days_ago=400)
|
customer = self._overdue_customer('علی رضایی', visit_days_ago=400)
|
||||||
@@ -1578,11 +1640,86 @@ class EntezarPageControlsTests(TestCase):
|
|||||||
Customer.objects.filter(pk=customer.pk).update(phone1='09121234567')
|
Customer.objects.filter(pk=customer.pk).update(phone1='09121234567')
|
||||||
|
|
||||||
for field, value in (('name', 'رضایی'), ('phone', '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.assertEqual(response.status_code, 200)
|
||||||
self.assertIn(customer.id,
|
self.assertIn(customer.id,
|
||||||
[obj.id for obj in response.context['customers']])
|
[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):
|
def test_sort_overdue_puts_biggest_delay_first(self):
|
||||||
oldest = self._overdue_customer('خیلی معوق', visit_days_ago=900)
|
oldest = self._overdue_customer('خیلی معوق', visit_days_ago=900)
|
||||||
newer = self._overdue_customer('کمتر معوق', visit_days_ago=300)
|
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']]
|
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))
|
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)
|
||||||
Reference in new issue
Block a user