improve bazdid
This commit is contained in:
1 parent
2aee85593f
commit
534a84f977
12 files changed
+2305
-339
No files matched your search
+5
-3
@@ -1,10 +1,10 @@
|
||||
Project Root: khatibi
|
||||
Generated At: 2026-10-20T14:01:53+00:00
|
||||
Python Files: 186
|
||||
Generated At: 2026-10-20T17:07:57+00:00
|
||||
Python Files: 187
|
||||
Django Files: 12
|
||||
JavaScript/TypeScript Files: 14
|
||||
HTML Files: 58
|
||||
Total Files: 744
|
||||
Total Files: 746
|
||||
Total Directories: 78
|
||||
|
||||
khatibi
|
||||
@@ -128,6 +128,7 @@ khatibi
|
||||
│ │ ├── 0075_cheque_funding_before_due.py
|
||||
│ │ ├── 0076_alter_smstemplate_text_help.py
|
||||
│ │ ├── 0077_alter_smstemplate_text_balance_help.py
|
||||
│ │ ├── 0078_action_visit_covering_index.py
|
||||
│ │ └── __init__.py
|
||||
│ ├── models
|
||||
│ │ ├── __init__.py
|
||||
@@ -321,6 +322,7 @@ khatibi
|
||||
│ ├── viewsBK.py
|
||||
│ └── visit_policy.py
|
||||
├── data9
|
||||
│ ├── backup-20261020-180934.sqlite3
|
||||
│ ├── db2 (2).sqlite32
|
||||
│ ├── db2.sqlite32
|
||||
│ ├── db222.sqlite3
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.25 on 2026-10-20 18:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('admin_panel', '0078_action_visit_covering_index'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customer',
|
||||
name='visit_hold_set_at',
|
||||
field=models.DateTimeField(blank=True, help_text='برای حسابرسی: آخرین باری که مقدار «قرار بعدی» نوشته شد.', null=True, verbose_name='زمان ثبت قرار'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='customer',
|
||||
name='visit_hold_until',
|
||||
field=models.DateTimeField(blank=True, help_text='تا پایان این روز پیگیری لازم نیست؛ از فرم «هماهنگی» تنظیم می\u200cشود.', null=True, verbose_name='قرار بعدی (تعویق بازدید)'),
|
||||
),
|
||||
]
|
||||
@@ -39,6 +39,25 @@ class Customer(models.Model):
|
||||
help_text="شمارههای نرمالشده (+98…) جداشده با کاما؛ خالی = شمارهٔ اول مشتری.",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# «قرار بعدی» — تعویقِ دستیِ بازدید (تصمیم محصولی)
|
||||
# ------------------------------------------------------------------
|
||||
# این ستون **اعتبار مستقل ندارد**: فقط وقتی اثر دارد که یک اقدام «هماهنگی»
|
||||
# فعال (``waite``/``finish``) با تاریخ آینده برای همان مشتری وجود داشته باشد.
|
||||
# منبع حقیقت همچنان جدول ``Action`` است و این مقدار یک میانبر سریع برای
|
||||
# نمایش/فیلتر است. اگر هماهنگی حذف/لغو شود، این مقدار خودبهخود بیاثر میشود
|
||||
# (نگاه کنید به ``visit_policy.manual_holds_map``).
|
||||
visit_hold_until = models.DateTimeField(
|
||||
null=True, blank=True,
|
||||
verbose_name="قرار بعدی (تعویق بازدید)",
|
||||
help_text="تا پایان این روز پیگیری لازم نیست؛ از فرم «هماهنگی» تنظیم میشود.",
|
||||
)
|
||||
visit_hold_set_at = models.DateTimeField(
|
||||
null=True, blank=True,
|
||||
verbose_name="زمان ثبت قرار",
|
||||
help_text="برای حسابرسی: آخرین باری که مقدار «قرار بعدی» نوشته شد.",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name_family
|
||||
|
||||
|
||||
@@ -161,27 +161,160 @@
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ===== مودال «ایجاد/ویرایش وظیفه» — mobile first =====
|
||||
همهٔ کلاسها با پیشوند ax- هستند تا با سایر بخشها تداخل نکنند. */
|
||||
#ActionBox .ax-card {
|
||||
width: calc(100% - 1rem); max-width: 44rem;
|
||||
max-height: calc(100vh - 1rem); display: flex; flex-direction: column;
|
||||
border-radius: .75rem; overflow: hidden;
|
||||
}
|
||||
#ActionBox .ax-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: .5rem; padding: .75rem 1rem; flex: 0 0 auto;
|
||||
background: #f8fafc; border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
#ActionBox .ax-title { font-size: .95rem; font-weight: 800; color: #0f172a; margin: 0; }
|
||||
#ActionBox .ax-sub { font-size: .75rem; color: #64748b; margin: .1rem 0 0; }
|
||||
#ActionBox .ax-body {
|
||||
padding: .75rem 1rem; overflow-y: auto; -webkit-overflow-scrolling: touch;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
#ActionBox .ax-foot {
|
||||
display: flex; gap: .5rem; justify-content: flex-start;
|
||||
padding: .6rem 1rem; flex: 0 0 auto;
|
||||
background: #f8fafc; border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
#ActionBox .ax-foot .button { margin: 0 !important; min-height: 2.6rem; flex: 1 1 0; }
|
||||
@media (min-width: 768px) {
|
||||
#ActionBox .ax-foot { justify-content: flex-start; }
|
||||
#ActionBox .ax-foot .button { flex: 0 0 auto; min-width: 7rem; }
|
||||
}
|
||||
|
||||
/* بخشها */
|
||||
#ActionBox .ax-section { padding: .6rem 0; border-bottom: 1px dashed #eef2f6; }
|
||||
#ActionBox .ax-section:last-of-type { border-bottom: 0; }
|
||||
#ActionBox .ax-legend {
|
||||
display: flex; align-items: center; gap: .4rem;
|
||||
font-size: .78rem; font-weight: 800; color: #0e7490; margin: 0 0 .5rem;
|
||||
}
|
||||
#ActionBox .ax-step {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 1.35rem; height: 1.35rem; border-radius: 999px;
|
||||
background: #33b5d3; color: #fff; font-size: .7rem; font-weight: 800;
|
||||
}
|
||||
|
||||
/* شبکهٔ فیلدها: موبایل تکستونه، دسکتاپ دوستونه */
|
||||
#ActionBox .ax-grid { display: grid; grid-template-columns: 1fr; gap: .6rem; }
|
||||
@media (min-width: 768px) {
|
||||
#ActionBox .ax-grid { grid-template-columns: 1fr 1fr; }
|
||||
#ActionBox .ax-span-2 { grid-column: 1 / -1; }
|
||||
}
|
||||
#ActionBox .ax-label {
|
||||
font-size: .74rem !important; font-weight: 700; color: #475569; margin-bottom: .25rem;
|
||||
}
|
||||
#ActionBox .ax-help {
|
||||
font-size: .7rem; color: #94a3b8; margin-top: .2rem; line-height: 1.5;
|
||||
}
|
||||
/* هدف لمسی بزرگتر روی موبایل */
|
||||
#ActionBox .input, #ActionBox .select select, #ActionBox .textarea {
|
||||
font-size: .85rem;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
#ActionBox .input, #ActionBox .select select { min-height: 2.6rem; }
|
||||
#ActionBox .textarea { min-height: 4.5rem; }
|
||||
}
|
||||
#ActionBox .select, #ActionBox .select select { width: 100%; }
|
||||
|
||||
/* ردیف تیک «انجام شده» */
|
||||
#ActionBox .ax-check {
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
padding: .55rem .7rem; border: 1px solid #e5e7eb; border-radius: .5rem;
|
||||
background: #fff; cursor: pointer; margin: 0;
|
||||
}
|
||||
#ActionBox .ax-check input { width: 1.05rem; height: 1.05rem; }
|
||||
|
||||
/* هشدار «آیا هماهنگی را انجام دادی؟» — فقط برای هماهنگیِ تیکخورده */
|
||||
#ActionBox .ax-warn {
|
||||
margin-top: .5rem; padding: .6rem .7rem;
|
||||
border: 1px solid #fcd34d; border-radius: .5rem; background: #fffbeb;
|
||||
color: #92400e; font-size: .78rem; line-height: 1.75;
|
||||
}
|
||||
#ActionBox .ax-warn .ax-warn-q { font-weight: 800; color: #78350f; margin: 0 0 .15rem; }
|
||||
#ActionBox .ax-warn .ax-warn-hint { margin: 0; }
|
||||
#ActionBox .ax-warn .ax-warn-actions {
|
||||
display: none; gap: .4rem; margin-top: .55rem; flex-wrap: wrap;
|
||||
}
|
||||
#ActionBox .ax-warn .ax-warn-actions .button { margin: 0 !important; flex: 1 1 auto; }
|
||||
@media (min-width: 768px) {
|
||||
#ActionBox .ax-warn .ax-warn-actions .button { flex: 0 0 auto; }
|
||||
}
|
||||
/* وقتی ارسال متوقف شده تا کاربر تأیید کند */
|
||||
#ActionBox .ax-warn.is-confirming {
|
||||
border-color: #f59e0b; box-shadow: 0 0 0 3px #fde68a66; background: #fef3c7;
|
||||
}
|
||||
#ActionBox .ax-warn.is-confirming .ax-warn-actions { display: flex; }
|
||||
#ActionBox .ax-warn:focus { outline: 2px solid #f59e0b; outline-offset: 2px; }
|
||||
|
||||
#ActionBox .ax-help-toggle {
|
||||
display: flex; align-items: center; gap: .45rem; width: 100%;
|
||||
padding: .55rem .7rem; border: 1px solid #bae6fd; border-radius: .5rem;
|
||||
background: #f0f9ff; color: #0369a1; font-size: .78rem; font-weight: 700;
|
||||
cursor: pointer; text-align: right; line-height: 1.4;
|
||||
}
|
||||
#ActionBox .ax-help-toggle:hover { background: #e0f2fe; }
|
||||
#ActionBox .ax-help-toggle .ax-ico {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 1.25rem; height: 1.25rem; border-radius: 999px; flex: 0 0 auto;
|
||||
background: #0284c7; color: #fff; font-size: .72rem; font-weight: 800;
|
||||
}
|
||||
#ActionBox .ax-help-toggle .ax-chev {
|
||||
margin-inline-start: auto; transition: transform .15s ease; font-size: .8rem;
|
||||
}
|
||||
#ActionBox .ax-help-toggle[aria-expanded="true"] .ax-chev { transform: rotate(180deg); }
|
||||
#ActionBox .ax-help-panel {
|
||||
margin-top: .4rem; padding: .7rem .8rem; border: 1px solid #e0f2fe;
|
||||
border-radius: .5rem; background: #fbfeff; font-size: .76rem; color: #334155;
|
||||
line-height: 1.85;
|
||||
}
|
||||
#ActionBox .ax-help-panel h4 {
|
||||
font-size: .78rem; font-weight: 800; color: #0f172a; margin: .6rem 0 .25rem;
|
||||
}
|
||||
#ActionBox .ax-help-panel h4:first-child { margin-top: 0; }
|
||||
#ActionBox .ax-help-panel ul { margin: 0; padding-inline-start: 1.1rem; }
|
||||
#ActionBox .ax-help-panel li { margin-bottom: .2rem; }
|
||||
#ActionBox .ax-help-panel .ax-eg {
|
||||
display: block; margin-top: .35rem; padding: .5rem .6rem;
|
||||
background: #fff; border: 1px dashed #bae6fd; border-radius: .4rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% if customer %}
|
||||
<form id="ActionBox" class="modal" method="post" action="{% url 'admin_panel:customerNewAction' customer.id %}">
|
||||
{% else %}
|
||||
<form id="ActionBox" class="modal" method="post" action="">
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% csrf_token %}
|
||||
<div class="modal-background closeActionBox"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title is-hidden">ایجاد وظیفه</p>
|
||||
<button type="button" class="delete closeActionBox" aria-label="close"></button>
|
||||
<div class="modal-card ax-card">
|
||||
<header class="modal-card-head ax-head">
|
||||
<div>
|
||||
<p class="ax-title">ایجاد وظیفه</p>
|
||||
{% if customer %}<p class="ax-sub">{{ customer.name_family }}</p>{% endif %}
|
||||
</div>
|
||||
<button type="button" class="delete closeActionBox" aria-label="بستن"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
|
||||
<div class="columns is-mobile is-multiline">
|
||||
<div class="field column is-6-desktop is-12-mobile m-0">
|
||||
<label class="label">نوع اقدام</label>
|
||||
<div class="control">
|
||||
<div class="select" style="width: 100%;">
|
||||
<select id="actionType" required name="actionType" style="width: 100%;">
|
||||
<section class="modal-card-body ax-body">
|
||||
|
||||
{# ---------- گام ۱: نوع اقدام و مسئول ---------- #}
|
||||
<div class="ax-section">
|
||||
<p class="ax-legend"><span class="ax-step">۱</span> نوع اقدام و مسئول</p>
|
||||
<div class="ax-grid">
|
||||
<div class="ax-field">
|
||||
<label class="label ax-label" for="actionType">نوع اقدام</label>
|
||||
<div class="select">
|
||||
<select id="actionType" required name="actionType">
|
||||
<option value="">انتخاب کنید</option>
|
||||
<option value="bazdid">بازدید</option>
|
||||
<option value="nasb">نصب</option>
|
||||
@@ -192,29 +325,11 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-6-desktop is-12-mobile m-0">
|
||||
<label class="label">تاریخ و ساعتی که کار باید انجام شود</label>
|
||||
<div class="control">
|
||||
<input type="text" class="input dateTimePickerAnjam">
|
||||
<input id="dateTimeAnjam" name="dateTimeAnjam" required type="text" class="input is-hidden dateTimePickerAnjam-alt">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-12-desktop is-12-mobile m-0">
|
||||
<div class="control">
|
||||
<label class="label mb-0">توضیحات برای یادآوری حین انجام کار:</label>
|
||||
<p class="is-size-7">راجب کاری که در آینده میخواهید انجام دهید توضیحاتی را بنویسید</p>
|
||||
<textarea id="desc" required name="desc" class="textarea" placeholder="مثلا: بامشتری تماس بگیرم برای هماهنگی تعویض فیلتر دستگاه"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-6-desktop is-12-mobile m-0">
|
||||
<label class="label">توسط</label>
|
||||
<div class="control">
|
||||
<div class="select" style="width: 100%;">
|
||||
<select id="userID" required name="userID" style="width: 100%;">
|
||||
|
||||
<div class="ax-field">
|
||||
<label class="label ax-label" for="userID">توسط</label>
|
||||
<div class="select">
|
||||
<select id="userID" required name="userID">
|
||||
<option value="">انتخاب کنید</option>
|
||||
{% if perms.admin_panel.ViewStore %}
|
||||
{% for auser in allUsers %}
|
||||
@@ -228,47 +343,153 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-12-desktop is-12-mobile m-0">
|
||||
<div class="control">
|
||||
<label class="checkbox">
|
||||
<input name="is_done" id="checkIsDone" type="checkbox">
|
||||
این کار انجام شده
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-12-desktop is-12-mobile m-0 showWhenDone is-hidden">
|
||||
<label class="label">تاریخ و ساعت اتمام کار</label>
|
||||
<div class="control">
|
||||
<input id="FinishDateTime" type="text" class="input dateTimePickerFinish">
|
||||
<input name="FinishDateTime" type="text" class="input dateTimePickerFinish-alt is-hidden">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-12-desktop is-12-mobile m-0 showWhenDone is-hidden">
|
||||
<div class="control">
|
||||
<label class="label mb-0">نتیجه</label>
|
||||
<p class="is-size-7">نتیجه کار یا پیگیری که انجام داده اید را بنویسید</p>
|
||||
<textarea name="result" id="actionResult" class="textarea" placeholder="نتیجه فعالیت انجام شده را بنویسید"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# ---------- گام ۲: زمانبندی ---------- #}
|
||||
<div class="ax-section">
|
||||
<p class="ax-legend"><span class="ax-step">۲</span> زمانبندی</p>
|
||||
<div class="ax-grid">
|
||||
<div class="ax-field">
|
||||
<label class="label ax-label">تاریخ و ساعتی که کار باید انجام شود</label>
|
||||
<input type="text" class="input dateTimePickerAnjam" autocomplete="off">
|
||||
<input id="dateTimeAnjam" name="dateTimeAnjam" required type="text" class="input is-hidden dateTimePickerAnjam-alt">
|
||||
<p class="ax-help">تاریخ انجام این وظیفه (ثبت در «لیست وظایف» همان روز).</p>
|
||||
</div>
|
||||
|
||||
{# فیلد «تا تاریخ پیگیری نشود» فقط برای «هماهنگی» #}
|
||||
<div class="ax-field showWhenCoordination is-hidden">
|
||||
<label class="label ax-label">تا تاریخ پیگیری نشود</label>
|
||||
<input type="text" class="input dateTimePickerHoldUntil" autocomplete="off">
|
||||
<input name="visitHoldUntil" id="visitHoldUntil" type="text" class="input is-hidden dateTimePickerHoldUntil-alt">
|
||||
<p class="ax-help">تا پایان این روز، مشتری در «انتظار بازدید» دیده نمیشود. خالی = ۱۴ روز.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# راهنمای بازشونده — فقط برای هماهنگی #}
|
||||
<div class="ax-help-wrap showWhenCoordination is-hidden" style="margin-top: .6rem;">
|
||||
<button type="button" class="ax-help-toggle" id="holdHelpToggle"
|
||||
aria-expanded="false" aria-controls="holdHelpPanel">
|
||||
<span class="ax-ico" aria-hidden="true">!</span>
|
||||
<span>راهنمای «هماهنگی» و «تا تاریخ پیگیری نشود»</span>
|
||||
<span class="ax-chev" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<div class="ax-help-panel is-hidden" id="holdHelpPanel">
|
||||
<h4>این تاریخ چه کاری میکند؟</h4>
|
||||
<p>میگوید «تا این تاریخ با این مشتری تماس نگیرید». در این بازه، مشتری از فهرست
|
||||
<span class="has-text-weight-bold">انتظار بازدید</span> پنهان میشود و روز بعد از آن
|
||||
تاریخ <span class="has-text-weight-bold">خودکار</span> برمیگردد. تاریخ تا
|
||||
<span class="has-text-weight-bold">پایان همان روز</span> معتبر است.</p>
|
||||
|
||||
<h4>مثال</h4>
|
||||
<div class="ax-eg">
|
||||
امروز ۱ آذر است. مشتری میگوید «سه هفتهٔ دیگر زنگ بزنید».<br>
|
||||
نوع = <b>هماهنگی</b> ← تیک «این کار انجام شده» ← «تا تاریخ پیگیری نشود» = <b>۲۲ آذر</b>.<br>
|
||||
نتیجه: از ۱ تا ۲۲ آذر مشتری در فهرست نیست؛ از ۲۳ آذر خودکار برمیگردد.
|
||||
</div>
|
||||
|
||||
<h4>نکات مهم</h4>
|
||||
<ul>
|
||||
<li><b>خالی + تیک «انجام شد»</b> ← سیستم خودش ۱۴ روز میگذارد.</li>
|
||||
<li><b>خالی + بدون تیک</b> ← هیچ تعویقی ثبت نمیشود؛ مشتری در فهرست میماند.</li>
|
||||
<li><b>فقط تمدید میکند، کوتاه نمیکند:</b> اگر قرار ۲۲ آذر باشد و همکار دیگری ۳۰ آذر بگذارد،
|
||||
قرار به ۳۰ آذر میرود؛ اما ۱۵ آذر نادیده گرفته میشود. برای کوتاهکردن،
|
||||
هماهنگی را حذف و دوباره ثبت کنید.</li>
|
||||
<li><b>حذف یا لغو هماهنگی</b> ← قرار بیاثر میشود و مشتری بلافاصله برمیگردد.</li>
|
||||
<li><b>سقف ۶ ماه</b> است؛ بیشتر از آن با پیام خطا رد میشود.</li>
|
||||
<li>این فیلد <b>تاریخ بازدید پرسنل نیست</b> و <b>بازدید حساب نمیشود</b>؛
|
||||
تاریخ آخرین بازدید و دورهٔ ۹ ماههٔ پیامک دست نمیخورد.
|
||||
برای اعزام پرسنل از اقدام نوع <b>بازدید</b> استفاده کنید.</li>
|
||||
<li>در مدت تعویق، <b>پیامک یادآوری ۹ ماههٔ بازدید</b> ارسال نمیشود.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---------- گام ۳: توضیحات ---------- #}
|
||||
<div class="ax-section">
|
||||
<p class="ax-legend"><span class="ax-step">۳</span> توضیحات و وضعیت</p>
|
||||
<div class="ax-grid">
|
||||
<div class="ax-field ax-span-2">
|
||||
<label class="label ax-label" for="desc">توضیحات برای یادآوری حین انجام کار</label>
|
||||
<textarea id="desc" required name="desc" class="textarea"
|
||||
placeholder="مثلا: با مشتری تماس بگیرم برای هماهنگی تعویض فیلتر دستگاه"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="ax-field ax-span-2">
|
||||
<label class="ax-check">
|
||||
<input name="is_done" id="checkIsDone" type="checkbox">
|
||||
<span>این کار انجام شده است</span>
|
||||
</label>
|
||||
|
||||
{# هشدار/تأیید: فقط برای «هماهنگی» با تیکِ «انجام شده» #}
|
||||
<div class="ax-warn is-hidden" id="holdDoneWarning" tabindex="-1"
|
||||
role="alert" aria-live="polite">
|
||||
<p class="ax-warn-q">آیا مطمئن هستی که هماهنگی را انجام دادی؟</p>
|
||||
<p class="ax-warn-hint">اگر هنوز هماهنگی نکردی، این تیک را بردار.</p>
|
||||
<div class="ax-warn-actions">
|
||||
<button type="button" id="holdConfirmYes" class="button is-warning is-small">
|
||||
بله، هماهنگی انجام شد
|
||||
</button>
|
||||
<button type="button" id="holdConfirmNo" class="button is-light is-small">
|
||||
نه، تیک را بردار
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ax-field showWhenDone is-hidden">
|
||||
<label class="label ax-label" for="FinishDateTime">تاریخ و ساعت اتمام کار</label>
|
||||
<input id="FinishDateTime" type="text" class="input dateTimePickerFinish" autocomplete="off">
|
||||
<input name="FinishDateTime" type="text" class="input dateTimePickerFinish-alt is-hidden">
|
||||
<p class="ax-help">خالی بماند، زمان ثبت در نظر گرفته میشود.</p>
|
||||
</div>
|
||||
|
||||
<div class="ax-field showWhenDone is-hidden">
|
||||
<label class="label ax-label" for="actionResult">نتیجه</label>
|
||||
<textarea name="result" id="actionResult" class="textarea"
|
||||
placeholder="نتیجهٔ کار یا پیگیری انجامشده را بنویسید"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
<button type="button" class="button closeActionBox">لغو</button>
|
||||
<footer class="modal-card-foot ax-foot">
|
||||
<button type="button" class="button closeActionBox">انصراف</button>
|
||||
<input id="sabtBtn05" type="submit" class="button is-success" value="ثبت"/>
|
||||
</footer>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// وضعیت مودال:
|
||||
// isEditMode ⇒ در حال ویرایش یک اقدام موجود (نه ایجاد)
|
||||
// holdAutoChecked ⇒ تیک «انجام شده» را خودِ سیستم برای هماهنگی زده است
|
||||
// holdConfirmed ⇒ کاربر تأیید کرده که هماهنگی انجام شده است
|
||||
var isEditMode = false;
|
||||
var holdAutoChecked = false;
|
||||
var holdConfirmed = false;
|
||||
|
||||
// پیکر «تا تاریخ پیگیری نشود» را ریست میکند.
|
||||
// نکته: pDatepicker یک Model برمیگرداند؛ اگر سلکتور هیچ المانی پیدا نکند
|
||||
// مقدار undefined میشود. بدون این محافظ، یک ناهمخوانی نام کلاس باعث
|
||||
// TypeError میشد و بقیهٔ منطقِ هندلر (پنهانکردن فیلدها، برداشتن تیک خودکار)
|
||||
// اجرا نمیشد.
|
||||
function resetHoldPicker() {
|
||||
if (dateTimePickerHoldUntil && typeof dateTimePickerHoldUntil.setDate === 'function') {
|
||||
dateTimePickerHoldUntil.setDate();
|
||||
}
|
||||
$("#visitHoldUntil").val('');
|
||||
}
|
||||
|
||||
$(".closeActionBox").click(function(){
|
||||
$("#ActionBox").removeClass("is-active");
|
||||
});
|
||||
|
||||
$("#showActionBox").click(function(){
|
||||
isEditMode = false;
|
||||
holdAutoChecked = false;
|
||||
holdConfirmed = false;
|
||||
$("#holdDoneWarning").addClass("is-hidden").removeClass("is-confirming");
|
||||
$("#ActionBox").addClass("is-active");
|
||||
{% if customer %}
|
||||
var NewActionUrl = "{% url 'admin_panel:customerNewAction' customer.id %}";
|
||||
@@ -282,18 +503,77 @@
|
||||
$("#actionResult").val('');
|
||||
dateTimePickerAnjam.setDate();
|
||||
dateTimePickerFinish.setDate();
|
||||
resetHoldPicker();
|
||||
$(".showWhenCoordination").addClass("is-hidden");
|
||||
closeHoldHelp();
|
||||
$("#sabtBtn05").val("ثبت");
|
||||
});
|
||||
|
||||
$("#checkIsDone").change(function() {
|
||||
if(this.checked) {
|
||||
$(".showWhenDone").removeClass("is-hidden");
|
||||
} else {
|
||||
$(".showWhenDone").addClass("is-hidden");
|
||||
// نمایش/پنهانکردن فیلدهای «انجام شده».
|
||||
function syncDoneFields() {
|
||||
$(".showWhenDone").toggleClass("is-hidden", !$("#checkIsDone").prop("checked"));
|
||||
}
|
||||
|
||||
// هشدار «آیا هماهنگی را انجام دادی؟» فقط برای هماهنگیِ تیکخورده نمایش داده میشود.
|
||||
function syncHoldWarning() {
|
||||
var active = ($("#actionType").val() === 'hamahangi') && $("#checkIsDone").prop("checked");
|
||||
$("#holdDoneWarning").toggleClass("is-hidden", !active);
|
||||
if (!active) {
|
||||
$("#holdDoneWarning").removeClass("is-confirming");
|
||||
holdConfirmed = false;
|
||||
}
|
||||
}
|
||||
|
||||
$("#checkIsDone").change(function() {
|
||||
// تغییر دستی کاربر ⇒ دیگر «تیک خودکار» نیست.
|
||||
holdAutoChecked = false;
|
||||
holdConfirmed = false;
|
||||
syncDoneFields();
|
||||
syncHoldWarning();
|
||||
});
|
||||
|
||||
// تأیید یا ردِ هشدار در لحظهٔ ثبت
|
||||
function showHoldConfirm() {
|
||||
var box = $("#holdDoneWarning");
|
||||
box.removeClass("is-hidden").addClass("is-confirming");
|
||||
if (box[0].scrollIntoView) {
|
||||
box[0].scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
box.trigger('focus');
|
||||
}
|
||||
|
||||
$("#holdConfirmYes").click(function() {
|
||||
holdConfirmed = true;
|
||||
// submit مستقیم روی المان فرم؛ اعتبارسنجی HTML5 در تلاش اول انجام شده است.
|
||||
$("#ActionBox")[0].submit();
|
||||
});
|
||||
|
||||
$("#holdConfirmNo").click(function() {
|
||||
holdConfirmed = false;
|
||||
holdAutoChecked = false;
|
||||
$("#checkIsDone").prop("checked", false);
|
||||
$("#holdDoneWarning").addClass("is-hidden").removeClass("is-confirming");
|
||||
syncDoneFields();
|
||||
$("#checkIsDone").trigger('focus');
|
||||
});
|
||||
|
||||
// گرفتن تأیید پیش از ثبتِ هماهنگیِ «انجامشده» (فقط در حالت ایجاد، نه ویرایش).
|
||||
$("#ActionBox").on('submit', function(event) {
|
||||
if (isEditMode || holdConfirmed) {
|
||||
return; // ویرایش، یا تأیید قبلی ⇒ ارسال آزاد
|
||||
}
|
||||
if ($("#actionType").val() !== 'hamahangi') {
|
||||
return; // سایر انواع اقدام دستنخورده
|
||||
}
|
||||
if (!$("#checkIsDone").prop("checked")) {
|
||||
return; // هماهنگیِ انجامنشده ⇒ بدون تعویق
|
||||
}
|
||||
event.preventDefault();
|
||||
showHoldConfirm();
|
||||
});
|
||||
|
||||
$(".actionBoxOpenB").click(function(){
|
||||
isEditMode = true;
|
||||
$("#sabtBtn05").val("ویرایش");
|
||||
var actionEditUrl = "{% url 'admin_panel:customerEditAction' 1111 2222 %}";
|
||||
var action_id = $(this).parent().attr("action_id");
|
||||
@@ -316,6 +596,12 @@
|
||||
}
|
||||
$("#ActionBox").addClass("is-active");
|
||||
$("#actionType").val(ActionParseData[0]['fields']['actionType']);
|
||||
// در ویرایش، تیکِ خودکار و تأیید نباید درگیر شوند.
|
||||
holdAutoChecked = false;
|
||||
holdConfirmed = false;
|
||||
// فیلد «تا تاریخ پیگیری نشود» با نوع اقدام همراستا شود (در حالت ویرایش هم).
|
||||
closeHoldHelp();
|
||||
toggleCoordinationField(ActionParseData[0]['fields']['actionType']);
|
||||
$("#desc").val(ActionParseData[0]['fields']['desc']);
|
||||
$("#userID").val(ActionParseData[0]['fields']['forUser']);
|
||||
if(ActionParseData[0]['fields']['status'] == 'finish'){
|
||||
@@ -326,6 +612,8 @@
|
||||
$(".showWhenDone").addClass("is-hidden");
|
||||
}
|
||||
$("#actionResult").val(ActionParseData[0]['fields']['result']);
|
||||
// هشدار «هماهنگی انجام شد» در ویرایش هم صرفاً نمایش داده میشود (بدون تأیید).
|
||||
syncHoldWarning();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -347,6 +635,74 @@
|
||||
},
|
||||
});
|
||||
|
||||
var dateTimePickerHoldUntil = $(".dateTimePickerHoldUntil").pDatepicker({
|
||||
altField: '.dateTimePickerHoldUntil-alt',
|
||||
initialValue: false,
|
||||
format: 'YYYY/MM/DD',
|
||||
calendar:{
|
||||
persian: {
|
||||
locale: 'fa'
|
||||
}
|
||||
},
|
||||
timePicker: {
|
||||
enabled: false,
|
||||
second:{
|
||||
enabled: false,
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// اگر پیکر ساخته نشد (مثلاً نام کلاس در قالب عوض شده باشد) هشدار بده؛
|
||||
// این کار جلوی خطاهای خاموش مثل «تقویم باز نمیشود» را میگیرد.
|
||||
if (!dateTimePickerHoldUntil) {
|
||||
console.warn('[ActionBox] pDatepicker برای «تا تاریخ پیگیری نشود» ساخته نشد؛ ' +
|
||||
'کلاس .dateTimePickerHoldUntil را در قالب بررسی کنید.');
|
||||
}
|
||||
|
||||
// فیلد «تا تاریخ پیگیری نشود» فقط برای اقدام «هماهنگی» نمایش داده میشود.
|
||||
function toggleCoordinationField(actionType) {
|
||||
var isCoordination = (actionType === 'hamahangi');
|
||||
$(".showWhenCoordination").toggleClass("is-hidden", !isCoordination);
|
||||
if (!isCoordination) {
|
||||
resetHoldPicker();
|
||||
closeHoldHelp(); // راهنما هم بسته شود تا در اقدام بعدی باز نماند
|
||||
}
|
||||
}
|
||||
|
||||
// ---- راهنمای بازشوندهٔ «هماهنگی» ----
|
||||
function closeHoldHelp() {
|
||||
$("#holdHelpPanel").addClass("is-hidden");
|
||||
$("#holdHelpToggle").attr("aria-expanded", "false");
|
||||
}
|
||||
|
||||
$("#holdHelpToggle").click(function(){
|
||||
var expanded = $(this).attr("aria-expanded") === "true";
|
||||
$("#holdHelpPanel").toggleClass("is-hidden", expanded);
|
||||
$(this).attr("aria-expanded", expanded ? "false" : "true");
|
||||
});
|
||||
|
||||
$("select#actionType").on('change', function(){
|
||||
var type = $(this).val();
|
||||
toggleCoordinationField(type);
|
||||
|
||||
if (type === 'hamahangi') {
|
||||
// پیشفرض: «هماهنگی» یعنی همین حالا تماس گرفته شد ⇒ تیک «انجام شده».
|
||||
// هشدار و تأیید، جلوی ثبتِ ناخواسته را میگیرد.
|
||||
if (!$("#checkIsDone").prop("checked")) {
|
||||
$("#checkIsDone").prop("checked", true);
|
||||
holdAutoChecked = true;
|
||||
}
|
||||
} else if (holdAutoChecked) {
|
||||
// تیک را خودمان زده بودیم ⇒ با تغییر نوع برمیداریم (بهویژه برای «بازدید»).
|
||||
$("#checkIsDone").prop("checked", false);
|
||||
holdAutoChecked = false;
|
||||
}
|
||||
|
||||
holdConfirmed = false;
|
||||
syncDoneFields();
|
||||
syncHoldWarning();
|
||||
});
|
||||
|
||||
var dateTimePickerAnjam = $(".dateTimePickerAnjam").pDatepicker({
|
||||
altField: '.dateTimePickerAnjam-alt',
|
||||
initialValue: true,
|
||||
|
||||
@@ -2,109 +2,379 @@
|
||||
{% load custom_tags %}
|
||||
{% block bodyPage %}
|
||||
|
||||
<div class="">
|
||||
<div class="columns m-0">
|
||||
<style>
|
||||
/* ===================================================================
|
||||
صفحهٔ انتظار بازدید — mobile first
|
||||
مقیاس فاصله: ۰٫۲۵rem (۴px) — همهٔ فاصلهها مضرب همین واحد است.
|
||||
=================================================================== */
|
||||
.ez-wrap { padding: .5rem; }
|
||||
@media (min-width: 769px) { .ez-wrap { padding: 1rem 1.25rem; } }
|
||||
|
||||
<div class="column is-12">
|
||||
<div class="buttons">
|
||||
<a href="#" id="openSearchBox" class="button is-info is-outlined is-rounded mr-0">جستجو</a>
|
||||
/* ---------- نوار آمار ---------- */
|
||||
.ez-stats {
|
||||
display: grid; grid-auto-flow: column; grid-auto-columns: minmax(8.5rem, 1fr);
|
||||
gap: .5rem; overflow-x: auto; padding-bottom: .25rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.ez-stats { grid-auto-flow: row; grid-template-columns: repeat(5, 1fr); overflow: visible; }
|
||||
}
|
||||
.ez-stat {
|
||||
display: block; border: 1px solid #e2e8f0; border-radius: .625rem;
|
||||
background: #fff; padding: .55rem .7rem; text-decoration: none; color: #334155;
|
||||
transition: border-color .15s ease, background-color .15s ease;
|
||||
}
|
||||
.ez-stat:hover { border-color: #94a3b8; }
|
||||
.ez-stat .ez-num {
|
||||
display: block; font-size: 1.2rem; font-weight: 800; line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ez-stat .ez-lbl { display: block; margin-top: .15rem; font-size: .7rem; color: #64748b; }
|
||||
.ez-stat.is-active {
|
||||
border-color: #33b5d3; background: #f1fbff; box-shadow: inset 0 -3px 0 #33b5d3;
|
||||
}
|
||||
.ez-stat.is-active .ez-lbl { color: #0e7490; font-weight: 700; }
|
||||
|
||||
/* ---------- تفکیک تأخیر ---------- */
|
||||
.ez-legend {
|
||||
display: flex; flex-wrap: wrap; gap: .25rem .9rem;
|
||||
margin: .6rem 0 .5rem; font-size: .72rem; color: #64748b;
|
||||
}
|
||||
.ez-legend b { font-variant-numeric: tabular-nums; color: #334155; }
|
||||
|
||||
/* ---------- ابزارکها ---------- */
|
||||
.ez-toolbar {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin: 0 0 .75rem;
|
||||
}
|
||||
.ez-toolbar .button { margin: 0 !important; }
|
||||
.ez-seg {
|
||||
display: inline-flex; border: 1px solid #e2e8f0; border-radius: .5rem; overflow: hidden;
|
||||
}
|
||||
.ez-seg a {
|
||||
padding: .3rem .6rem; font-size: .72rem; color: #475569; text-decoration: none;
|
||||
background: #fff; border-inline-end: 1px solid #e2e8f0; white-space: nowrap;
|
||||
}
|
||||
.ez-seg a:last-child { border-inline-end: 0; }
|
||||
.ez-seg a:hover { background: #f1f5f9; }
|
||||
.ez-seg a.is-active { background: #33b5d3; color: #fff; font-weight: 700; }
|
||||
|
||||
/* ---------- جدول ---------- */
|
||||
.ez-table { width: 100%; margin: 0; font-size: .82rem; border-collapse: collapse; }
|
||||
@media (min-width: 900px) { .ez-table { table-layout: fixed; } }
|
||||
.ez-table thead th {
|
||||
position: sticky; top: 0; z-index: 2; background: #33b5d3; color: #06323d;
|
||||
font-size: .72rem; font-weight: 700; padding: .5rem .35rem; white-space: nowrap;
|
||||
border: 0; border-bottom: 1px solid #22a5c1;
|
||||
}
|
||||
.ez-table tbody td {
|
||||
padding: .45rem .3rem; vertical-align: middle; text-align: center;
|
||||
line-height: 1.4; border: 0; border-bottom: 1px solid #eef2f6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* تاریخها و شمارهها هرگز به دو خط نشکنند */
|
||||
.ez-table tbody td.ez-nowrap { white-space: nowrap; }
|
||||
.ez-table tbody tr:hover td { background: #f8fbfd; }
|
||||
|
||||
/* شمارندهٔ ردیف: بهجای یک ستون کامل، چیپ کنار نام */
|
||||
.ez-idx {
|
||||
display: inline-block; min-width: 1.55rem; margin-inline-end: .3rem;
|
||||
padding: .05rem .25rem; border-radius: .3rem; background: #f1f5f9;
|
||||
color: #64748b; font-size: .68rem; font-weight: 700;
|
||||
font-variant-numeric: tabular-nums; text-align: center;
|
||||
}
|
||||
.ez-name { font-weight: 700; font-size: .84rem; color: #0f172a; }
|
||||
.ez-name:hover { color: #0e7490; }
|
||||
.ez-sub { font-size: .68rem; color: #94a3b8; margin-top: .1rem; }
|
||||
|
||||
/* اعداد و شمارهها */
|
||||
.ez-num { font-variant-numeric: tabular-nums; }
|
||||
.ez-phone {
|
||||
direction: ltr; unicode-bidi: isolate;
|
||||
font-variant-numeric: tabular-nums; letter-spacing: .01em;
|
||||
}
|
||||
.ez-muted { color: #cbd5e1; }
|
||||
|
||||
.ez-badge {
|
||||
display: inline-block; padding: .1rem .45rem; border-radius: 999px;
|
||||
font-size: .68rem; font-weight: 700; white-space: nowrap; line-height: 1.6;
|
||||
}
|
||||
.ez-badge.danger { background: #fee2e2; color: #b91c1c; }
|
||||
.ez-badge.success { background: #dcfce7; color: #15803d; }
|
||||
.ez-badge.warning { background: #fef3c7; color: #b45309; }
|
||||
.ez-badge.mixed { background: #e0f2fe; color: #0369a1; }
|
||||
|
||||
/* تأخیر بهصورت چیپ (خوانا در یک نگاه) */
|
||||
.ez-late {
|
||||
display: inline-block; min-width: 4.4rem; padding: .1rem .4rem;
|
||||
border-radius: .3rem; font-size: .72rem; font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ez-late.lv0 { background: #f8fafc; color: #94a3b8; }
|
||||
.ez-late.lv1 { background: #fffbeb; color: #b45309; }
|
||||
.ez-late.lv2 { background: #fef2f2; color: #dc2626; }
|
||||
.ez-late.lv3 { background: #fde8e8; color: #991b1b; }
|
||||
|
||||
/* آدرس: حداکثر دو خط (بدون شکستن چیدمان) */
|
||||
.ez-addr {
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
overflow: hidden; text-align: right; font-size: .78rem; color: #475569;
|
||||
}
|
||||
|
||||
/* ---------- حالت کارت (موبایل) ---------- */
|
||||
.ez-only-desktop { display: none !important; }
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.ez-table thead { display: none; }
|
||||
.ez-table, .ez-table tbody, .ez-table tr, .ez-table td { display: block; width: 100%; }
|
||||
.ez-table tbody tr {
|
||||
display: flex; flex-wrap: wrap; align-items: baseline;
|
||||
background: #fff; border: 1px solid #e5e7eb; border-radius: .625rem;
|
||||
margin-bottom: .5rem; padding: .55rem .7rem;
|
||||
}
|
||||
.ez-table tbody tr:hover td { background: transparent; }
|
||||
.ez-table tbody td {
|
||||
flex: 1 0 100%; display: flex; flex-wrap: wrap; align-items: baseline;
|
||||
gap: .3rem; padding: .12rem 0; border: 0; text-align: right;
|
||||
}
|
||||
.ez-table tbody td::before {
|
||||
content: attr(data-label); flex: 0 0 6.5rem;
|
||||
font-size: .7rem; color: #94a3b8;
|
||||
}
|
||||
/* سرِ کارت: نام مشتری، بدون برچسب */
|
||||
.ez-table tbody td.ez-card-head {
|
||||
padding-bottom: .35rem; margin-bottom: .25rem;
|
||||
border-bottom: 1px dashed #eef2f6;
|
||||
}
|
||||
.ez-table tbody td.ez-card-head::before,
|
||||
.ez-table tbody td.ez-nolabel::before { content: none; }
|
||||
.ez-actions { flex: 1 0 100%; display: flex; gap: .35rem; margin-top: .4rem; }
|
||||
.ez-actions .button { margin: 0 !important; flex: 1 1 0; }
|
||||
.ez-addr { -webkit-line-clamp: 3; }
|
||||
.ez-legend { font-size: .7rem; }
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.ez-table thead { display: table-header-group; }
|
||||
.ez-table tbody { display: table-row-group; }
|
||||
.ez-table tr { display: table-row; }
|
||||
.ez-table td { display: table-cell; }
|
||||
.ez-table td::before { content: none; }
|
||||
.ez-only-desktop { display: table-cell !important; }
|
||||
.ez-only-mobile { display: none !important; }
|
||||
.ez-actions { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="ez-wrap">
|
||||
<div class="columns m-0">
|
||||
<div class="column is-12 p-0">
|
||||
{# ===== نوار آمار: هر عدد لینک فیلتر ===== #}
|
||||
<div class="ez-stats">
|
||||
<a href="?view=all&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="ez-stat {% if view == 'all' %}is-active{% endif %}">
|
||||
<span class="ez-num">{{ counts.total }}</span>
|
||||
<span class="ez-lbl">کل معوق</span>
|
||||
</a>
|
||||
<a href="?view=uncoordinated&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="ez-stat {% if view == 'uncoordinated' %}is-active{% endif %}">
|
||||
<span class="ez-num has-text-danger">{{ counts.uncoordinated }}</span>
|
||||
<span class="ez-lbl">بدون قرار</span>
|
||||
</a>
|
||||
<a href="?view=coordinated&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="ez-stat {% if view == 'coordinated' %}is-active{% endif %}">
|
||||
<span class="ez-num has-text-success">{{ counts.coordinated }}</span>
|
||||
<span class="ez-lbl">با قرار</span>
|
||||
</a>
|
||||
<a href="?view=manual&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="ez-stat {% if view == 'manual' %}is-active{% endif %}">
|
||||
<span class="ez-num has-text-warning-dark">{{ counts.manual }}</span>
|
||||
<span class="ez-lbl">قرار هماهنگی</span>
|
||||
</a>
|
||||
<a href="?view=scheduled&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="ez-stat {% if view == 'scheduled' %}is-active{% endif %}">
|
||||
<span class="ez-num has-text-link">{{ counts.scheduled }}</span>
|
||||
<span class="ez-lbl">بازدید برنامهریزیشده</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# تفکیک مدت تأخیر در نمای جاری #}
|
||||
<div class="ez-legend">
|
||||
<span>تأخیر:</span>
|
||||
<span><b>{{ counts.buckets.lt30 }}</b> زیر ۳۰ روز</span>
|
||||
<span><b>{{ counts.buckets.30to90 }}</b> ۳۰–۹۰ روز</span>
|
||||
<span><b>{{ counts.buckets.90to180 }}</b> ۹۰–۱۸۰ روز</span>
|
||||
<span><b>{{ counts.buckets.gt180 }}</b> بیش از ۶ ماه</span>
|
||||
</div>
|
||||
|
||||
{# ===== ابزارکها: جستجو و مرتبسازی ===== #}
|
||||
<div class="ez-toolbar">
|
||||
<a href="#" id="openSearchBox" class="button is-info is-outlined is-rounded is-small">
|
||||
جستجو{% if searchVal %}: «{{ searchVal }}»{% endif %}
|
||||
</a>
|
||||
|
||||
<span class="is-size-7 has-text-grey">مرتبسازی</span>
|
||||
<span class="ez-seg">
|
||||
<a href="?view={{ view }}&sort=oldest{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="{% if sort == 'oldest' %}is-active{% endif %}">قدیمیترین</a>
|
||||
<a href="?view={{ view }}&sort=overdue{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="{% if sort == 'overdue' %}is-active{% endif %}">بیشترین تأخیر</a>
|
||||
<a href="?view={{ view }}&sort=newest{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="{% if sort == 'newest' %}is-active{% endif %}">جدیدترین</a>
|
||||
</span>
|
||||
|
||||
{% if searchVal %}
|
||||
<a href="?view={{ view }}&sort={{ sort }}" class="button is-small is-light">حذف جستجو</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="table has-text-centered is-bordered is-striped is-narrow is-hoverable is-fullwidth">
|
||||
<!-- Your table content -->
|
||||
<table class="table ez-table">
|
||||
<colgroup>
|
||||
<col style="width: 23%">
|
||||
<col style="width: 13%">
|
||||
<col style="width: 10%">
|
||||
<col style="width: 11%">
|
||||
<col style="width: 10%">
|
||||
<col style="width: 9%">
|
||||
<col style="width: 11%">
|
||||
<col style="width: 13%">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr style="
|
||||
background: #33b5d3;
|
||||
">
|
||||
<th >ردیف</th>
|
||||
<th style="min-width: 200px;">نام و نام خانودگی</th>
|
||||
<th >آخرین بازدید</th>
|
||||
<th >تلفن</th>
|
||||
<th style="min-width: 400px;">آدرس</th>
|
||||
<tr>
|
||||
<th>نام و نام خانوادگی</th>
|
||||
<th>وضعیت قرار</th>
|
||||
<th>قرار هماهنگی</th>
|
||||
<th>بازدید برنامهریزیشده</th>
|
||||
<th>آخرین بازدید</th>
|
||||
<th>تأخیر</th>
|
||||
<th>تلفن</th>
|
||||
<th>آدرس</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for customer in customers %}
|
||||
<tr>
|
||||
<td>{{ forloop.counter }}</td>
|
||||
<td>
|
||||
{# سرِ ردیف: چیپ شماره + نام (شمارهٔ ردیف دیگر ستون جداگانه ندارد) #}
|
||||
<td data-label="نام" class="ez-card-head" style="text-align: right;">
|
||||
<input class="is-hidden fid fidv-20" value="20">
|
||||
<a target="_blank" href="{% url 'admin_panel:customerAction' customer.id %}" class="tdvl name">
|
||||
{% if customer.name_family|length >= 2 %} {{ customer.name_family }} {% else %} نامعلوم {% endif %}
|
||||
<span class="ez-idx">{{ forloop.counter0|add:customers.start_index }}</span>
|
||||
<a target="_blank" href="{% url 'admin_panel:customerAction' customer.id %}" class="tdvl name ez-name">
|
||||
{% if customer.name_family|length >= 2 %}{{ customer.name_family }}{% else %}نامعلوم{% endif %}
|
||||
</a>
|
||||
{% if customer.idCode %}<div class="ez-sub">کد: {{ customer.idCode }}</div>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="tdvl lastView">
|
||||
{{ customer.last_bazdid_dt | toJalali }}
|
||||
</div>
|
||||
{% with appt=appointment_rows|row_for:customer.id %}
|
||||
<td data-label="وضعیت" class="ez-nolabel">
|
||||
{% if appt.manual and appt.scheduled or appt.manual and appt.coordination %}
|
||||
<span class="ez-badge mixed">قرار + بازدید</span>
|
||||
{% elif appt.manual %}
|
||||
<span class="ez-badge warning">قرار هماهنگی</span>
|
||||
{% elif appt.scheduled or appt.coordination %}
|
||||
<span class="ez-badge success">بازدید برنامهریزیشده</span>
|
||||
{% else %}
|
||||
<span class="ez-badge danger">بدون قرار</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="قرار هماهنگی" class="ez-num ez-nowrap">
|
||||
{% if appt.manual %}{{ appt.manual|toJalali }}{% else %}<span class="ez-muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td data-label="بازدید برنامه" class="ez-num ez-nowrap">
|
||||
{% if appt.scheduled %}{{ appt.scheduled|toJalali }}
|
||||
{% elif appt.coordination %}{{ appt.coordination|toJalali }}<div class="ez-sub">در انتظار تماس</div>
|
||||
{% else %}<span class="ez-muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td data-label="آخرین بازدید" class="ez-num ez-nowrap lastView">
|
||||
{% if appt.last_visit %}{{ appt.last_visit|toJalali }}{% else %}<span class="ez-muted">بدون بازدید</span>{% endif %}
|
||||
</td>
|
||||
<td data-label="تأخیر" class="ez-nowrap">
|
||||
{% if appt.overdue_days >= 180 %}
|
||||
<span class="ez-late lv3">{{ appt.overdue_days }} روز</span>
|
||||
{% elif appt.overdue_days >= 90 %}
|
||||
<span class="ez-late lv2">{{ appt.overdue_days }} روز</span>
|
||||
{% elif appt.overdue_days >= 30 %}
|
||||
<span class="ez-late lv1">{{ appt.overdue_days }} روز</span>
|
||||
{% else %}
|
||||
<span class="ez-late lv0">{{ appt.overdue_days }} روز</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endwith %}
|
||||
|
||||
<td>
|
||||
<div class="tdvl mobile_1">
|
||||
{{ customer.phone1 }}
|
||||
</div>
|
||||
<div class="tdvl is-hidden mobile_2">
|
||||
09384999655
|
||||
</div>
|
||||
<div class="tdvl is-hidden mobile_3">
|
||||
{{ customer.phone2 }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="tdvl address">
|
||||
{{ customer.address }}
|
||||
<td data-label="تلفن" class="ez-nowrap">
|
||||
<span class="tdvl mobile_1 ez-phone">
|
||||
{% if customer.phone1 %}{{ customer.phone1 }}{% else %}<span class="ez-muted">—</span>{% endif %}
|
||||
</span>
|
||||
<span class="tdvl is-hidden mobile_2">{{ customer.phone2 }}</span>
|
||||
<span class="tdvl is-hidden mobile_3">{{ customer.phone3 }}</span>
|
||||
{# دکمههای اقدام سریع — فقط موبایل #}
|
||||
<div class="ez-actions">
|
||||
{% if customer.phone1 %}
|
||||
<a href="tel:{{ customer.phone1 }}" class="button is-success is-small">تماس</a>
|
||||
{% endif %}
|
||||
<a target="_blank" href="{% url 'admin_panel:customerAction' customer.id %}" class="button is-info is-small">ثبت اقدام</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<td data-label="آدرس">
|
||||
<div class="tdvl address ez-addr" title="{{ customer.address }}">{{ customer.address }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="8" class="has-text-centered py-5">
|
||||
<span class="has-text-grey">موردی در این نما پیدا نشد.</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="">
|
||||
{% if customers.has_previous %}
|
||||
<a class="button is-danger" href="?{% if searchVal %}searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}&page={{ customers.previous_page_number }}">صفحه قبل</a>
|
||||
{% endif %}
|
||||
|
||||
{% if customers.has_next %}
|
||||
<a class="button is-danger" href="?{% if searchVal %}searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}&page={{ customers.next_page_number }}">صفحه بعد</a>
|
||||
{# ===== صفحهبندی ===== #}
|
||||
<div class="is-flex is-align-items-center is-justify-content-space-between mt-3 mb-5">
|
||||
{% if customers.has_previous %}
|
||||
<a class="button is-danger is-small" href="?view={{ view }}&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}&page={{ customers.previous_page_number }}">صفحه قبل</a>
|
||||
{% else %}<span></span>{% endif %}
|
||||
|
||||
{% endif %}
|
||||
<span class="is-size-7 has-text-grey">
|
||||
صفحهٔ {{ customers.number }} از {{ customers.paginator.num_pages }} ·
|
||||
{{ customers.paginator.count }} مورد
|
||||
</span>
|
||||
|
||||
</div>
|
||||
{% 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>
|
||||
{% else %}<span></span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal SearchBox">
|
||||
<div class="modal-background closeSearch"></div>
|
||||
<div class="modal-content">
|
||||
<!-- -->
|
||||
<form method="get" class="columns is-mobile is-multiline">
|
||||
{# حفظ نما و ترتیب هنگام جستوجو #}
|
||||
<input type="hidden" name="view" value="{{ view }}">
|
||||
<input type="hidden" name="sort" value="{{ sort }}">
|
||||
<div class="field column is-7-desktop is-12-mobile m-0">
|
||||
<label class="label">جستجو کنید</label>
|
||||
<div class="control">
|
||||
<input name="searchVal" class="input search" type="text" placeholder="">
|
||||
<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">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="searchOn">
|
||||
<!--
|
||||
<option value="name">نام و نام خانوادگی</option>
|
||||
<option value="code">کدمشتری</option>
|
||||
<option value="phone">تلفن</option> !-->
|
||||
<option value="address">آدرس</option>
|
||||
<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" id="searchbtn" value="جستجو مشتری" type="submit">
|
||||
<input class="button is-success is-fullwidth" id="searchbtn" value="جستجو مشتری" type="submit">
|
||||
</div>
|
||||
<!-- -->
|
||||
</form>
|
||||
|
||||
|
||||
@@ -112,109 +382,17 @@
|
||||
<button class="modal-close is-large closeSearch" aria-label="close"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal BazdidF">
|
||||
<div class="modal-background closeBazdidF"></div>
|
||||
<div class="modal-content">
|
||||
<div class="columns is-mobile is-multiline">
|
||||
|
||||
<div class="field column is-12-desktop is-12-mobile m-0">
|
||||
<label class="label">نام ونام خانوادگی</label>
|
||||
<div class="control">
|
||||
<input class="input rf_name" id="bz_name" type="text" placeholder="نام نام خانوادگی">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-4-desktop is-12-mobile m-0">
|
||||
<label class="label">تلفن همراه</label>
|
||||
<div class="control">
|
||||
<input class="input rf_mobile" id="phone-1" type="text" placeholder="">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-4-desktop is-12-mobile m-0">
|
||||
<label class="label">تاریخ</label>
|
||||
<div class="control">
|
||||
<input name="bazdidDateTime" id="persianDateP" class="input bz_DateTime" type="text" placeholder="">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="field column is-4-desktop is-12-mobile m-0">
|
||||
<label class="label">توسط</label>
|
||||
<div class="select">
|
||||
<select>
|
||||
<option>کدام خدمات؟</option>
|
||||
<option>خدمات 1</option>
|
||||
<option>خدمات 2</option>
|
||||
<option>خدمات 3</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-4-desktop is-12-mobile m-0">
|
||||
<label class="label">زمان مراجعه</label>
|
||||
<div class="select">
|
||||
<select>
|
||||
<option>زمان را انتخاب کنید</option>
|
||||
<option>صبح</option>
|
||||
<option>ظهر</option>
|
||||
<option>بعد از ظهر</option>
|
||||
<option>غروب</option>
|
||||
<option>شب</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-8-desktop is-12-mobile m-0">
|
||||
<label class="label">توضیحات</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea" placeholder="توضیحات"></textarea> </div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-12-desktop is-12-mobile m-0">
|
||||
<input class="button is-success" id="editCS" value="ایجاد بازدید" type="submit">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<button class="modal-close is-large closeBazdidF" aria-label="close"></button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block jsEnd %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
|
||||
kamaDatepicker('persianDateP', { buttonsColor: "red", forceFarsiDigits: true });
|
||||
|
||||
$(".closeBazdidF").click(function(){
|
||||
$(".BazdidF").removeClass("is-active")
|
||||
$("#openSearchBox").click(function(e) {
|
||||
e.preventDefault();
|
||||
$(".SearchBox").addClass("is-active");
|
||||
});
|
||||
|
||||
$("#openSearchBox").click(function(){
|
||||
$(".SearchBox").addClass("is-active")
|
||||
$(".closeSearch").click(function() {
|
||||
$(".SearchBox").removeClass("is-active");
|
||||
});
|
||||
|
||||
$(".closeSearch").click(function(){
|
||||
$(".SearchBox").removeClass("is-active")
|
||||
});
|
||||
|
||||
$(".openRow").click(function(){
|
||||
var id = $(this).parent().parent().find('.fid').val().trim();
|
||||
var name = $(this).parent().parent().find('.name').text().trim();
|
||||
var mobile_1 = $(this).parent().parent().find('.mobile_1').text().trim();
|
||||
|
||||
var address = $(this).parent().parent().find('.address').text().trim();
|
||||
//alert(id)
|
||||
$(".BazdidF").find('#bz_name').val(name);
|
||||
$(".BazdidF").find('#phone-1').val(mobile_1);
|
||||
|
||||
//$("#bedehiStatus").val('وضعیت')
|
||||
|
||||
$(".BazdidF").addClass("is-active")
|
||||
});
|
||||
} );
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -20,6 +20,20 @@ def toJalaliDT(value):
|
||||
def has_group(user, group_name):
|
||||
return user.groups.filter(name=group_name).exists()
|
||||
|
||||
|
||||
def row_for(mapping, key):
|
||||
"""مقدار یک نگاشت با کلید دلخواه (برای دسترسی به دیکشنری در قالب).
|
||||
|
||||
``{{ appointment_rows|row_for:customer.id }}`` ⇒ مقدار مربوط به همان مشتری،
|
||||
یا ``None`` اگر نبود. (جایگزین سبکِ ``.get()`` در قالبهای Django.)
|
||||
"""
|
||||
if not mapping:
|
||||
return None
|
||||
try:
|
||||
return mapping.get(key)
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@register.simple_tag
|
||||
def customer_phones(customer):
|
||||
"""شمارههای مشتری برای ستون اطلاعات مشتری.
|
||||
@@ -35,4 +49,5 @@ def customer_phones(customer):
|
||||
|
||||
register.filter('has_group', has_group)
|
||||
register.filter('toJalali', toJalali)
|
||||
register.filter('toJalaliDT', toJalaliDT)
|
||||
register.filter('toJalaliDT', toJalaliDT)
|
||||
register.filter('row_for', row_for)
|
||||
@@ -12,8 +12,10 @@ from django.utils import timezone
|
||||
from ..models import Action, Customer
|
||||
from ..sms.triggers import notify_visit_scheduled
|
||||
from ..visit_policy import (REASON_CANCEL, REASON_CREATE, REASON_EDIT,
|
||||
as_aware, normalize_finish_datetime,
|
||||
refresh_last_bazdid, validate_action_payload)
|
||||
as_aware, clear_visit_hold, default_hold_until,
|
||||
normalize_finish_datetime, normalize_hold_until,
|
||||
refresh_last_bazdid, set_visit_hold,
|
||||
validate_action_payload)
|
||||
|
||||
|
||||
@login_required(login_url='/panel/p_login/')
|
||||
@@ -80,6 +82,8 @@ def _action_payload_from_request(request):
|
||||
'status': Action.Status.FINISH if is_done else Action.Status.WAITE,
|
||||
'actionDateTime': _parse_form_datetime(request.POST.get('dateTimeAnjam')),
|
||||
'finishDateTime': _parse_form_datetime(request.POST.get('FinishDateTime')),
|
||||
# فقط برای «هماهنگی» معنا دارد؛ تاریخِ «تا کِی پیگیری نشود».
|
||||
'holdRaw': _parse_form_datetime(request.POST.get('visitHoldUntil')),
|
||||
'desc': request.POST.get('desc', None),
|
||||
'result': request.POST.get('result', None),
|
||||
'userID': request.POST.get('userID', None),
|
||||
@@ -87,6 +91,26 @@ def _action_payload_from_request(request):
|
||||
}
|
||||
|
||||
|
||||
def _apply_visit_hold(customer_obj, payload):
|
||||
"""نوشتن/پاککردن «قرار بعدی» بر اساس اقدام ثبت/ویرایششده.
|
||||
|
||||
قواعد:
|
||||
* فقط برای اقدام نوع «هماهنگی» معنا دارد.
|
||||
* اگر تاریخ قرار داده شده باشد ⇒ ``set_visit_hold`` (فقط رو به جلو).
|
||||
* اگر هماهنگی «انجام شد» تیک خورده و تاریخ قرار خالی است ⇒ پیشفرض ۱۴ روز.
|
||||
* اگر هماهنگی لغو شد (``cancell``) یا نوع عوض شد ⇒ قرار پاک میشود.
|
||||
"""
|
||||
if payload['actionType'] != Action.ActionType.HAMAHANGI:
|
||||
return
|
||||
raw = payload.get('holdRaw')
|
||||
if raw is not None:
|
||||
target = normalize_hold_until(raw)
|
||||
set_visit_hold(customer_obj, target)
|
||||
return
|
||||
if payload['status'] == Action.Status.FINISH:
|
||||
set_visit_hold(customer_obj, default_hold_until())
|
||||
|
||||
|
||||
def _reject(request, error, redirect_to):
|
||||
"""پیام فارسی خطا به کاربر و بازگشت بدون ذخیرهٔ چیزی."""
|
||||
messages.error(request, '؛ '.join(error.messages))
|
||||
@@ -98,6 +122,9 @@ def customerNewAction(request ,c_id):
|
||||
payload = _action_payload_from_request(request)
|
||||
try:
|
||||
validate_action_payload(payload)
|
||||
# اعتبارسنجی «قرار بعدی» پیش از هر نوشتنی (سقف ۶ ماه و تاریخ گذشته).
|
||||
if payload['actionType'] == Action.ActionType.HAMAHANGI and payload['holdRaw']:
|
||||
normalize_hold_until(payload['holdRaw'])
|
||||
except ValidationError as error:
|
||||
return _reject(request, error, '/panel/customer/' + str(c_id) + '/action')
|
||||
|
||||
@@ -119,6 +146,8 @@ def customerNewAction(request ,c_id):
|
||||
|
||||
# last_bazdid_dt فقط از تابع واحد بازمحاسبه میشود؛ نه از finishDateTime.
|
||||
refresh_last_bazdid(customer_obj, reason=REASON_CREATE, user=request.user)
|
||||
# «قرار بعدی» فقط برای هماهنگی.
|
||||
_apply_visit_hold(customer_obj, payload)
|
||||
|
||||
# پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
|
||||
if newAction.actionType == Action.ActionType.BAZDID:
|
||||
@@ -147,6 +176,8 @@ def customerEditAction(request ,c_id, action_id):
|
||||
payload = _action_payload_from_request(request)
|
||||
try:
|
||||
validate_action_payload(payload)
|
||||
if payload['actionType'] == Action.ActionType.HAMAHANGI and payload['holdRaw']:
|
||||
normalize_hold_until(payload['holdRaw'])
|
||||
except ValidationError as error:
|
||||
return _reject(
|
||||
request, error,
|
||||
@@ -174,6 +205,12 @@ def customerEditAction(request ,c_id, action_id):
|
||||
# (تا مقداری که این رکورد ساخته بود با منبع حقیقت همراستا شود).
|
||||
reason = REASON_CANCEL if (was_done and not payload['is_done']) else REASON_EDIT
|
||||
refresh_last_bazdid(customer_obj, reason=reason, user=request.user)
|
||||
# «قرار بعدی»: با ویرایش همراستا میشود؛ اگر اقدام دیگر هماهنگی نباشد یا
|
||||
# هماهنگی لغو شود، قرار دستی پاک میشود.
|
||||
if payload['actionType'] == Action.ActionType.HAMAHANGI and newAction.status != Action.Status.CANCELL:
|
||||
_apply_visit_hold(customer_obj, payload)
|
||||
else:
|
||||
clear_visit_hold(customer_obj)
|
||||
|
||||
#return redirect('/panel/customer/'+str(c_id)+'/action')
|
||||
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
|
||||
|
||||
+173
-20
@@ -2,7 +2,7 @@ import datetime
|
||||
import os
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Sum, Q
|
||||
from django.db.models import Sum, Q, Case, When, Value
|
||||
from django.shortcuts import redirect, render, get_object_or_404
|
||||
from django.core.paginator import Paginator
|
||||
from django.utils import timezone
|
||||
@@ -18,7 +18,19 @@ from ..sms.phones import (normalize_primary_values,
|
||||
save_primary_normalized)
|
||||
from ..sms.triggers import notify_payment_thanks, notify_welcome
|
||||
from ..sms.rules import customer_toggle_map
|
||||
from ..visit_policy import (last_visit_map, next_appointment_map, visit_due_at)
|
||||
from ..visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED,
|
||||
appointment_sources_map, last_visit_map, visit_due_at,
|
||||
visit_due_date, visit_period_months)
|
||||
|
||||
# نماهای صفحهٔ «انتظار» (فیلتر ?view=). پیشفرض = فقط «بدون قرار» تا رفتار
|
||||
# قبلی صفحه برای کاربران حفظ شود.
|
||||
ENTEZAR_VIEWS = ('uncoordinated', 'manual', 'scheduled', 'coordinated', 'all')
|
||||
ENTEZAR_DEFAULT_VIEW = 'uncoordinated'
|
||||
# حالتهای مرتبسازی (?sort=):
|
||||
# oldest ⇒ قدیمیترین بازدید اول (پیشفرض/اولویت کارشناس)
|
||||
# newest ⇒ جدیدترین بازدید اول
|
||||
# overdue ⇒ بیشترین تأخیر اول
|
||||
ENTEZAR_SORTS = ('oldest', 'newest', 'overdue')
|
||||
from ._helpers import logger, _parse_jalali_date, _parse_phone_rows
|
||||
|
||||
|
||||
@@ -329,32 +341,59 @@ def customerEdit(request, c_id):
|
||||
|
||||
@login_required(login_url='/panel/p_login/')
|
||||
def entezar(request):
|
||||
"""فهرست «منتظر بازدید».
|
||||
"""فهرست «منتظر بازدید» + تفکیک «با قرار» و «بدون قرار».
|
||||
|
||||
منبع حقیقت تاریخ بازدید، ``visit_policy.last_visit_map`` است (نه ستون کششدهٔ
|
||||
``last_bazdid_dt``) تا این صفحه و اسکنر پیامک هرگز واگرا نشوند. دورهٔ بازدید هم
|
||||
از همان قالب پیامک خوانده میشود. مشتری دارای «تعویق فعال» (اقدام هماهنگی
|
||||
در انتظار با تاریخ آینده) از فهرست خارج و به صف خدمات پس از فروش سپرده میشود.
|
||||
منبع حقیقت تاریخ بازدید ``visit_policy.last_visit_map`` است و منبع «قرار بعدی»
|
||||
``appointment_sources_map`` (سه منبع: قرار دستی، بازدید برنامهریزیشده،
|
||||
هماهنگیِ در انتظار). هر مشتری دقیقاً در یکی از چهار گروه زیر است:
|
||||
|
||||
* ``A`` — معوق بدون هیچ قرار (پیشفرض نمایش؛ رفتار قبلی صفحه)
|
||||
* ``B`` — معوق با قرار دستی (تعویق از فرم هماهنگی)
|
||||
* ``C`` — معوق با بازدید برنامهریزیشده یا هماهنگی در انتظار
|
||||
* ``D`` — معوق با بیش از یک منبعِ قرار
|
||||
|
||||
فیلتر با ``?view=`` و قابل ترکیب با جستوجوی آدرس است.
|
||||
"""
|
||||
searchVal = request.GET.get('searchVal', '')
|
||||
searchOn = request.GET.get('searchOn', '')
|
||||
view = (request.GET.get('view') or ENTEZAR_DEFAULT_VIEW).strip().lower()
|
||||
if view not in ENTEZAR_VIEWS:
|
||||
view = ENTEZAR_DEFAULT_VIEW # مقدار نامعتبر ⇒ پیشفرض، بدون خطا
|
||||
sort = (request.GET.get('sort') or 'oldest').strip().lower()
|
||||
if sort not in ENTEZAR_SORTS:
|
||||
sort = 'oldest'
|
||||
searchStatus = True
|
||||
full_path = request.get_full_path()
|
||||
|
||||
now = timezone.now()
|
||||
due_cut = visit_due_at(now=now)
|
||||
# دوره فقط یک بار خوانده میشود (جلوگیری از N+1 در حلقهٔ محاسبهٔ تأخیر).
|
||||
period_months = visit_period_months()
|
||||
# مرز تاریخ به وقت محلی (نه UTC) تا صفحه و اسکنر پیامک یک جواب بدهند.
|
||||
due_cut_date = due_cut.astimezone(timezone.get_current_timezone()).date()
|
||||
|
||||
visits = last_visit_map(now=now)
|
||||
appointments = next_appointment_map(now=now)
|
||||
sources = appointment_sources_map(now=now)
|
||||
|
||||
customers_objj = Customer.objects.filter(
|
||||
laghv_bazdid=False, name_family__isnull=False,
|
||||
installDate__lte=due_cut_date,
|
||||
)
|
||||
if searchOn == 'address':
|
||||
customers_objj = customers_objj.filter(address__contains=searchVal)
|
||||
# جستوجو: نام، آدرس، تلفن (همه شمارهها) و کد شناسایی.
|
||||
if searchVal:
|
||||
if searchOn == 'address':
|
||||
customers_objj = customers_objj.filter(address__contains=searchVal)
|
||||
elif searchOn == 'name':
|
||||
customers_objj = customers_objj.filter(name_family__contains=searchVal)
|
||||
elif searchOn == 'phone':
|
||||
customers_objj = customers_objj.filter(
|
||||
Q(phone1__contains=searchVal) | Q(phone2__contains=searchVal)
|
||||
| Q(phone3__contains=searchVal)
|
||||
)
|
||||
elif searchOn == 'code':
|
||||
customers_objj = customers_objj.filter(idCode__contains=searchVal)
|
||||
else:
|
||||
searchStatus = False
|
||||
else:
|
||||
searchStatus = False
|
||||
|
||||
@@ -363,28 +402,142 @@ def entezar(request):
|
||||
customer_id for customer_id in customers_objj.values_list('id', flat=True)
|
||||
if visits.get(customer_id) is None or visits[customer_id] <= due_cut
|
||||
]
|
||||
customers_objj = customers_objj.filter(id__in=stale_ids)
|
||||
|
||||
# «قرار بعدی» (هماهنگی توافقشده یا بازدید برنامهریزیشده) یعنی این مشتری
|
||||
# در این فهرست جایی ندارد تا آن تاریخ.
|
||||
pending_ids = [cid for cid in stale_ids if cid in appointments]
|
||||
if pending_ids:
|
||||
customers_objj = customers_objj.exclude(id__in=pending_ids)
|
||||
# تفکیک چهارگروهی (MECE): هر مشتری فقط در یک گروه.
|
||||
groups = {'A': [], 'B': [], 'C': [], 'D': []}
|
||||
for customer_id in stale_ids:
|
||||
customer_sources = sources.get(customer_id) or {}
|
||||
manual = SOURCE_MANUAL in customer_sources
|
||||
scheduled = (SOURCE_SCHEDULED in customer_sources
|
||||
or SOURCE_COORDINATION in customer_sources)
|
||||
if manual and scheduled:
|
||||
groups['D'].append(customer_id)
|
||||
elif manual:
|
||||
groups['B'].append(customer_id)
|
||||
elif scheduled:
|
||||
groups['C'].append(customer_id)
|
||||
else:
|
||||
groups['A'].append(customer_id)
|
||||
|
||||
customers_objj = customers_objj.order_by('last_bazdid_dt', 'id')
|
||||
selected = {
|
||||
'uncoordinated': groups['A'],
|
||||
'manual': groups['B'] + groups['D'],
|
||||
'scheduled': groups['C'] + groups['D'],
|
||||
'coordinated': groups['B'] + groups['C'] + groups['D'],
|
||||
'all': stale_ids,
|
||||
}[view]
|
||||
|
||||
# «مدت تأخیر» = از چه زمانی بازدید عقب افتاده است (به روز).
|
||||
# مبنای محاسبه «موعد بازدید» است، نه خود بازدید: موعد = بازدید + دوره.
|
||||
# اینطور مشتریِ تازهمعوق هم در بازهٔ کوتاه قرار میگیرد و عدد معنا پیدا میکند.
|
||||
today = now.astimezone(timezone.get_current_timezone()).date()
|
||||
overdue_days = {}
|
||||
for customer_id in selected:
|
||||
anchor = visits.get(customer_id)
|
||||
if anchor is not None:
|
||||
due = visit_due_date(anchor, months=period_months)
|
||||
base = due.astimezone(timezone.get_current_timezone()).date()
|
||||
else:
|
||||
# بدون بازدید: مقطع دوره (تاریخ نصب خیلی قدیمی، عدد را بیمعنا میکند).
|
||||
base = due_cut_date
|
||||
overdue_days[customer_id] = max((today - base).days, 0)
|
||||
|
||||
# مرتبسازی:
|
||||
# oldest ⇒ قدیمیترین بازدید اول (اولویت کارشناس)
|
||||
# newest ⇒ جدیدترین بازدید اول
|
||||
# overdue ⇒ بیشترین تأخیر اول (چه کسی بیشتر از همه عقب افتاده)
|
||||
if sort == 'overdue':
|
||||
ordered_ids = sorted(selected, key=lambda cid: (-overdue_days.get(cid, 0), cid))
|
||||
customers_objj = customers_objj.filter(id__in=ordered_ids)
|
||||
if ordered_ids:
|
||||
# ترتیب در لایهٔ دیتابیس با CASE/WHEN (بدون بار حافظهای).
|
||||
ordering = Case(
|
||||
*[When(id=cid, then=Value(index))
|
||||
for index, cid in enumerate(ordered_ids)],
|
||||
output_field=models.IntegerField(),
|
||||
)
|
||||
customers_objj = customers_objj.order_by(ordering)
|
||||
else:
|
||||
customers_objj = customers_objj.order_by('id')
|
||||
else:
|
||||
# مرتبسازی بر مبنای **تاریخ مؤثر بازدید** (از Action)، نه ستون ممکنالقدمت.
|
||||
ordered_ids = sorted(
|
||||
selected,
|
||||
key=lambda cid: (
|
||||
(visits.get(cid) or due_cut),
|
||||
cid,
|
||||
),
|
||||
reverse=(sort == 'newest'),
|
||||
)
|
||||
customers_objj = customers_objj.filter(id__in=ordered_ids)
|
||||
ordering = Case(
|
||||
*[When(id=cid, then=Value(index))
|
||||
for index, cid in enumerate(ordered_ids)],
|
||||
output_field=models.IntegerField(),
|
||||
)
|
||||
customers_objj = customers_objj.order_by(ordering)
|
||||
|
||||
# آمار «تأخیر» (روی کل گروه انتخابی، نه فقط صفحهٔ جاری).
|
||||
buckets = {'lt30': 0, '30to90': 0, '90to180': 0, 'gt180': 0}
|
||||
for days in overdue_days.values():
|
||||
if days < 30:
|
||||
buckets['lt30'] += 1
|
||||
elif days < 90:
|
||||
buckets['30to90'] += 1
|
||||
elif days < 180:
|
||||
buckets['90to180'] += 1
|
||||
else:
|
||||
buckets['gt180'] += 1
|
||||
|
||||
counts = {
|
||||
'total': len(stale_ids),
|
||||
'uncoordinated': len(groups['A']),
|
||||
'manual': len(groups['B']),
|
||||
'scheduled': len(groups['C']),
|
||||
'both': len(groups['D']),
|
||||
'coordinated': len(groups['B']) + len(groups['C']) + len(groups['D']),
|
||||
'selected': len(selected),
|
||||
'buckets': buckets,
|
||||
}
|
||||
|
||||
try:
|
||||
entezar_bazdid = CacheTable.objects.get(key='9mahbazdid')
|
||||
entezar_bazdid.value = len(stale_ids) - len(pending_ids)
|
||||
# کارت داشبورد = «کارِ فوری» = بدون قرار.
|
||||
entezar_bazdid.value = counts['uncoordinated']
|
||||
entezar_bazdid.save()
|
||||
except CacheTable.DoesNotExist:
|
||||
pass
|
||||
|
||||
paginator = Paginator(customers_objj, 20) # Show 25 contacts per page.
|
||||
paginator = Paginator(customers_objj, 20)
|
||||
page_number = request.GET.get('page', None)
|
||||
customerHa = paginator.get_page(page_number)
|
||||
|
||||
return render(request, 'admin_panel/entezar.html', {'customers': customerHa, 'searchStatus': searchStatus, 'full_path': full_path, 'searchVal': searchVal, 'searchOn':searchOn})
|
||||
# اطلاعات هر ردیف برای قالب: قرارها + مدت تأخیر + آخرین بازدید مؤثر.
|
||||
appointment_rows = {}
|
||||
for customer in customerHa.object_list:
|
||||
entry = sources.get(customer.id) or {}
|
||||
appointment_rows[customer.id] = {
|
||||
'manual': entry.get(SOURCE_MANUAL),
|
||||
'scheduled': entry.get(SOURCE_SCHEDULED),
|
||||
'coordination': entry.get(SOURCE_COORDINATION),
|
||||
'overdue_days': overdue_days.get(customer.id, 0),
|
||||
'last_visit': visits.get(customer.id),
|
||||
'group': (
|
||||
'D' if (SOURCE_MANUAL in entry and (
|
||||
SOURCE_SCHEDULED in entry or SOURCE_COORDINATION in entry))
|
||||
else 'B' if SOURCE_MANUAL in entry
|
||||
else 'C' if (SOURCE_SCHEDULED in entry or SOURCE_COORDINATION in entry)
|
||||
else 'A'
|
||||
),
|
||||
}
|
||||
|
||||
return render(request, 'admin_panel/entezar.html', {
|
||||
'customers': customerHa, 'searchStatus': searchStatus, 'full_path': full_path,
|
||||
'searchVal': searchVal, 'searchOn': searchOn,
|
||||
'view': view, 'counts': counts, 'appointment_rows': appointment_rows,
|
||||
'sort': sort, 'sorts': ENTEZAR_SORTS,
|
||||
'views': ENTEZAR_VIEWS,
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url='/panel/p_login/')
|
||||
|
||||
+238
-47
@@ -295,57 +295,23 @@ def refresh_last_bazdid(customer, *, reason=REASON_EDIT, user=None, now=None):
|
||||
|
||||
|
||||
def next_appointment_map(*, now=None):
|
||||
"""نگاشت ``customer_id → زودترین «قرار بعدی» آینده`` — با تجمیع در دیتابیس.
|
||||
"""نگاشت ``customer_id → زودترین «قرار بعدی» آینده``.
|
||||
|
||||
منابع قرار (فقط آینده):
|
||||
* ``hamahangi`` با وضعیت ``waite`` **یا** ``finish``،
|
||||
* ``bazdid`` با وضعیت ``waite`` (بازدید برنامهریزیشده).
|
||||
اجتماع سه منبع (همه با تاریخ آینده):
|
||||
|
||||
تاریخ قرار = ``actionDateTime`` و اگر خالی بود ``finishDateTime``.
|
||||
فقط برای ``entezar`` و اسکنر پیامک استفاده میشود و هرگز در «آخرین بازدید»
|
||||
دخالت نمیکند.
|
||||
* ``manual`` — ستون ``Customer.visit_hold_until`` (با اعتبارسنجی)،
|
||||
* ``scheduled`` — ``bazdid + waite`` (بازدید برنامهریزیشده)،
|
||||
* ``coordination`` — ``hamahangi`` با وضعیت ``waite`` یا ``finish`` که
|
||||
تاریخش آینده است (سازگاری با داده و رفتار قبلی).
|
||||
|
||||
این تابع تصمیم «مشتری در فهرست انتظار هست یا نه» و همچنین اسکنر پیامک را
|
||||
تغذیه میکند؛ «آخرین بازدید» هیچگاه از اینجا خوانده نمیشود.
|
||||
"""
|
||||
now = _now(now)
|
||||
# همان نکتهٔ ``last_visit_map``: ساعت مرجع باید aware بماند تا Django آن را
|
||||
# دوباره با منطقهٔ محلی adapt نکند.
|
||||
now_utc = now.astimezone(dt_timezone.utc)
|
||||
|
||||
# چرا دو کوئری جدا؟ با یک شرط ``OR`` روی نوع اقدام، بهینهساز SQLite از ایندکس
|
||||
# ``act_visit_idx`` استفاده نمیکند و کوئری ~۱۹۳ms طول میکشد. با تقسیم به
|
||||
# کوئریهای ساده، هر کدام دقیقاً با ایندکس (actionType + status) اجرا میشوند.
|
||||
# (از ``union()`` استفاده نمیکنیم چون در این ترکیب، ردیفها را از دست میدهد.)
|
||||
hamahangi = Action.objects.filter(
|
||||
actionType=Action.ActionType.HAMAHANGI,
|
||||
status__in=(Action.Status.WAITE, Action.Status.FINISH),
|
||||
).filter(
|
||||
models.Q(actionDateTime__gt=now_utc)
|
||||
| models.Q(actionDateTime__isnull=True, finishDateTime__gt=now_utc)
|
||||
).values_list("customer_id", "actionDateTime", "finishDateTime")
|
||||
|
||||
planned_visit = Action.objects.filter(
|
||||
actionType=Action.ActionType.BAZDID,
|
||||
status=Action.Status.WAITE,
|
||||
actionDateTime__gt=now_utc,
|
||||
).values_list("customer_id", "actionDateTime")
|
||||
|
||||
combined = {}
|
||||
|
||||
def _remember(customer_id, when):
|
||||
when = as_aware(when)
|
||||
if when is None:
|
||||
return
|
||||
current = combined.get(customer_id)
|
||||
if current is None or when < current:
|
||||
combined[customer_id] = when
|
||||
|
||||
for customer_id, action_dt, finish_dt in hamahangi.iterator():
|
||||
# تاریخ قرار: تاریخ اقدام؛ و برای هماهنگیِ بدون تاریخ اقدام، تاریخ اتمام.
|
||||
_remember(customer_id, action_dt or finish_dt)
|
||||
|
||||
for customer_id, action_dt in planned_visit.iterator():
|
||||
_remember(customer_id, action_dt)
|
||||
|
||||
return combined
|
||||
return {
|
||||
customer_id: min(sources.values())
|
||||
for customer_id, sources in appointment_sources_map(now=now).items()
|
||||
}
|
||||
|
||||
|
||||
def next_appointment(customer, *, now=None):
|
||||
@@ -355,6 +321,134 @@ def next_appointment(customer, *, now=None):
|
||||
return next_appointment_map(now=now).get(customer.pk)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# منابع «قرار بعدی» بهتفکیک — برای صفحهٔ انتظار و آمار
|
||||
# ---------------------------------------------------------------------------
|
||||
# سه منبع وجود دارد و هر سه با هم «قرار بعدی» را میسازند:
|
||||
#
|
||||
# ۱. ``manual`` — ستون ``Customer.visit_hold_until`` (تعویق دستی از فرم هماهنگی)
|
||||
# ۲. ``scheduled`` — اقدام بازدید با وضعیت ``waite`` و تاریخ آینده
|
||||
# ۳. ``coordination`` — اقدام هماهنگیِ در انتظار (کارشناس تیک «انجام شد» نزده)
|
||||
#
|
||||
# تفکیک اینها فقط برای نمایش/آمار است؛ تصمیم «مشتری در فهرست هست یا نه» را
|
||||
# همان ``next_appointment_map`` میگیرد (اجتماع هر سه).
|
||||
|
||||
SOURCE_MANUAL = "manual"
|
||||
SOURCE_SCHEDULED = "scheduled"
|
||||
SOURCE_COORDINATION = "coordination"
|
||||
|
||||
|
||||
def _future_actions(action_type, statuses, *, now_utc):
|
||||
"""اقدامهای آیندهٔ یک نوع با تاریخ مؤثر (تاریخ اقدام، وگرنه تاریخ اتمام)."""
|
||||
return (
|
||||
Action.objects.filter(actionType=action_type, status__in=statuses)
|
||||
.filter(
|
||||
models.Q(actionDateTime__gt=now_utc)
|
||||
| models.Q(actionDateTime__isnull=True, finishDateTime__gt=now_utc)
|
||||
)
|
||||
.values_list("customer_id", "actionDateTime", "finishDateTime")
|
||||
)
|
||||
|
||||
|
||||
def _action_appointments(action_type, statuses, *, now=None):
|
||||
"""نگاشت ``customer_id → زودترین تاریخِ آینده`` برای یک نوع اقدام."""
|
||||
now_utc = _now(now).astimezone(dt_timezone.utc)
|
||||
result = {}
|
||||
for customer_id, action_dt, finish_dt in _future_actions(
|
||||
action_type, statuses, now_utc=now_utc
|
||||
).iterator():
|
||||
when = as_aware(action_dt) or as_aware(finish_dt)
|
||||
if when is None:
|
||||
continue
|
||||
current = result.get(customer_id)
|
||||
if current is None or when < current:
|
||||
result[customer_id] = when
|
||||
return result
|
||||
|
||||
|
||||
def scheduled_visits_map(*, now=None):
|
||||
"""قرارهای «بازدید برنامهریزیشده»: ``bazdid + waite`` با تاریخ آینده."""
|
||||
return _action_appointments(
|
||||
Action.ActionType.BAZDID, (Action.Status.WAITE,), now=now,
|
||||
)
|
||||
|
||||
|
||||
def coordination_holds_map(*, now=None):
|
||||
"""قرارهای «هماهنگی» با تاریخ آینده.
|
||||
|
||||
هر دو وضعیت ``waite`` و ``finish`` در نظر گرفته میشوند (سازگاری با رفتار
|
||||
قبلی). حالت ``waite`` یعنی کارشناس هنوز تیک «انجام شد» نزده، و ``finish``
|
||||
یعنی تماس انجام شده و قرار برای آینده گذاشته شده است.
|
||||
"""
|
||||
return _action_appointments(
|
||||
Action.ActionType.HAMAHANGI,
|
||||
(Action.Status.WAITE, Action.Status.FINISH),
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def manual_holds_map(*, now=None):
|
||||
"""قرارهای دستی (ستون ``visit_hold_until``) — **با اعتبارسنجی**.
|
||||
|
||||
قاعدهٔ قطعی: ستون بهتنهایی معتبر نیست. فقط وقتی اثر دارد که یک اقدام
|
||||
«هماهنگی» با وضعیت ``waite`` یا ``finish`` و **تاریخ آینده** برای همان
|
||||
مشتری وجود داشته باشد. بنابراین:
|
||||
|
||||
* حذف هماهنگی ⇒ قرار خودکار بیاثر میشود،
|
||||
* لغو هماهنگی (``cancell``) ⇒ بیاثر میشود،
|
||||
* هماهنگیِ گذشته ⇒ بیاثر میشود،
|
||||
* کهنهماندن ستون هیچ اثر مخربی ندارد.
|
||||
|
||||
مرجع هماهنگی: هر ``hamahangi`` فعالی با تاریخ آینده (نه لزوماً برابر با ستون)،
|
||||
چون کاربر میتواند هماهنگی را ویرایش کند و تاریخ دیگری بگذارد.
|
||||
"""
|
||||
now = _now(now)
|
||||
rows = (
|
||||
Customer.objects.filter(visit_hold_until__gt=now)
|
||||
.values_list("id", "visit_hold_until")
|
||||
.iterator()
|
||||
)
|
||||
holds = {customer_id: as_aware(until) for customer_id, until in rows}
|
||||
if not holds:
|
||||
return {}
|
||||
|
||||
# مرجع اعتبار: وجود یک اقدام «هماهنگیِ فعال» برای همان مشتری (نه لزوماً با
|
||||
# تاریخ آینده). این نکته حیاتی است: سناریوی رایج «همین حالا تماس گرفتم و قرار
|
||||
# دو هفتهٔ بعد گذاشتم» تاریخ اقدام را «امروز» میذارد؛ اگر شرط آینده بودن تاریخ
|
||||
# اقدام را بگذاریم، همین مورد (که هدف اصلی این قابلیت است) بیاثر میشد.
|
||||
# پس فقط وضعیت مهم است: ``waite`` (در انتظار) یا ``finish`` (انجام شد).
|
||||
# ``cancell`` حساب نمیشود و حذف رکورد هم خودکار بیاثر میکند.
|
||||
active_coordination = set(
|
||||
Action.objects.filter(
|
||||
actionType=Action.ActionType.HAMAHANGI,
|
||||
status__in=(Action.Status.WAITE, Action.Status.FINISH),
|
||||
).values_list("customer_id", flat=True)
|
||||
)
|
||||
return {
|
||||
customer_id: until
|
||||
for customer_id, until in holds.items()
|
||||
if customer_id in active_coordination and until is not None
|
||||
}
|
||||
|
||||
|
||||
def appointment_sources_map(*, now=None):
|
||||
"""نگاشت ``customer_id → {منبع: تاریخ}`` برای هر سه منبع (فقط آینده).
|
||||
|
||||
برای صفحهٔ انتظار و آمار استفاده میشود؛ تصمیم «خارج از فهرست» را
|
||||
:func:`next_appointment_map` میگیرد که اجتماع همینهاست.
|
||||
"""
|
||||
now = _now(now)
|
||||
merged = {}
|
||||
for source, values in (
|
||||
(SOURCE_MANUAL, manual_holds_map(now=now)),
|
||||
(SOURCE_SCHEDULED, scheduled_visits_map(now=now)),
|
||||
(SOURCE_COORDINATION, coordination_holds_map(now=now)),
|
||||
):
|
||||
for customer_id, when in values.items():
|
||||
merged.setdefault(customer_id, {})[source] = when
|
||||
return merged
|
||||
|
||||
|
||||
# نام قدیمی برای سازگاری با فراخوانیهای موجود.
|
||||
def active_deferrals(*, now=None):
|
||||
"""نام قدیمیِ :func:`next_appointment_map` (سازگاری عقبرو)."""
|
||||
@@ -366,6 +460,100 @@ def active_deferral(customer, *, now=None):
|
||||
return next_appointment(customer, now=now)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# نوشتن «قرار دستی» روی مشتری
|
||||
# ---------------------------------------------------------------------------
|
||||
HOLD_DEFAULT_DAYS = 14
|
||||
HOLD_MAX_DAYS = 183 # ≈ ۶ ماه
|
||||
|
||||
|
||||
def end_of_local_day(moment):
|
||||
"""پایان همان روز به وقت محلی (بهصورت aware).
|
||||
|
||||
تصمیم محصولی: «قرار تا ۱ آذر» یعنی تا **آخر** ۱ آذر، نه تا ساعت ثبت.
|
||||
اگر صرفاً ``actionDateTime`` را ذخیره کنیم، قرارِ «امروز» بلافاصله منقضی
|
||||
میشد و مشتری همان لحظه به فهرست برمیگشت.
|
||||
"""
|
||||
moment = as_aware(moment)
|
||||
if moment is None:
|
||||
return None
|
||||
local = moment.astimezone(timezone.get_current_timezone())
|
||||
end_of_day = datetime.combine(local.date(), time(23, 59, 59))
|
||||
try:
|
||||
return timezone.make_aware(end_of_day, timezone.get_current_timezone())
|
||||
except Exception:
|
||||
return timezone.make_aware(
|
||||
end_of_day, timezone.get_current_timezone(), is_dst=False,
|
||||
)
|
||||
|
||||
|
||||
def default_hold_until(*, now=None, days=HOLD_DEFAULT_DAYS):
|
||||
"""پیشفرض «قرار بعدی»: پایان روزِ ``days`` روز بعد."""
|
||||
base = _now(now) + relativedelta.relativedelta(days=days)
|
||||
return end_of_local_day(base)
|
||||
|
||||
|
||||
def set_visit_hold(customer, until, *, now=None):
|
||||
"""ثبت قرار دستی؛ فقط اگر **جلوتر** از مقدار فعلی باشد مینویسد.
|
||||
|
||||
قاعدهٔ «فقط رو به جلو» (مثل ``refresh_last_bazdid``) جلوی این را میگیرد که
|
||||
ثبت هماهنگی توسط کارشناس دوم، قرارِ دورترِ قبلی را عقب بکشد.
|
||||
خروجی: ``True`` اگر نوشته شد.
|
||||
"""
|
||||
if customer is None or customer.pk is None:
|
||||
return False
|
||||
target = end_of_local_day(until)
|
||||
if target is None:
|
||||
return False
|
||||
current = as_aware(customer.visit_hold_until)
|
||||
if current is not None and current >= target:
|
||||
return False
|
||||
Customer.objects.filter(pk=customer.pk).update(
|
||||
visit_hold_until=target, visit_hold_set_at=_now(now),
|
||||
)
|
||||
customer.visit_hold_until = target
|
||||
return True
|
||||
|
||||
|
||||
def clear_visit_hold(customer):
|
||||
"""پاککردن قرار دستی (مثلاً وقتی بازدید واقعی انجام شد)."""
|
||||
if customer is None or customer.pk is None:
|
||||
return False
|
||||
if customer.visit_hold_until is None and customer.visit_hold_set_at is None:
|
||||
return False
|
||||
Customer.objects.filter(pk=customer.pk).update(
|
||||
visit_hold_until=None, visit_hold_set_at=None,
|
||||
)
|
||||
customer.visit_hold_until = None
|
||||
customer.visit_hold_set_at = None
|
||||
return True
|
||||
|
||||
|
||||
def normalize_hold_until(raw, *, now=None):
|
||||
"""اعتبارسنجی و نرمالسازی «قرار بعدی» از ورودی فرم.
|
||||
|
||||
قواعد:
|
||||
* مقدار خالی ⇒ ``None`` (بدون تعویق).
|
||||
* تاریخ باید در آینده یا امروز باشد (پایان روز پذیرفته است).
|
||||
* حداکثر ``HOLD_MAX_DAYS`` روز جلوتر (≈ ۶ ماه) — جلوگیری از «تعویق بیپایان».
|
||||
|
||||
خطای ``ValidationError`` با پیام فارسی برمیگرداند تا در ویو نشان داده شود.
|
||||
"""
|
||||
moment = as_aware(raw)
|
||||
if moment is None:
|
||||
return None
|
||||
target = end_of_local_day(moment)
|
||||
today_end = end_of_local_day(_now(now))
|
||||
if target < today_end:
|
||||
raise ValidationError("تاریخ قرار نمیتواند پیش از امروز باشد.")
|
||||
limit = today_end + relativedelta.relativedelta(days=HOLD_MAX_DAYS)
|
||||
if target > limit:
|
||||
raise ValidationError(
|
||||
f"قرار بعدی حداکثر میتواند {HOLD_MAX_DAYS} روز (۶ ماه) جلوتر باشد."
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# دورهٔ بازدید (منبع واحد)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -399,6 +587,9 @@ def visit_due_date(anchor, *, months=None):
|
||||
"""تاریخ موعد (تقویمی) برای یک لنگر: ``anchor + N ماه``.
|
||||
|
||||
تنها مرجع محاسبهٔ موعد، هم در صفحهٔ «انتظار» و هم در اسکنر پیامک.
|
||||
|
||||
توجه: ``months`` را در حلقهها **حتماً** پاس بدهید؛ وگرنه برای هر لنگر یک
|
||||
کوئری خواندنِ قالب پیامک زده میشود (باگ N+1).
|
||||
"""
|
||||
anchor = as_aware(anchor)
|
||||
if anchor is None:
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
# «قرار بعدی» بازدید و صفحهٔ انتظار — توضیح کامل عملکرد
|
||||
|
||||
این سند رفتار نهایی صفحهٔ `/panel/entezar/`، فیلد «تا تاریخ پیگیری نشود» در فرم
|
||||
اقدام، و قواعد محاسبه را توضیح میدهد. هدف: بعد از این تغییر، **هماهنگیِ انجامشده
|
||||
هم** مشتری را تا تاریخ توافق از فهرست خارج کند و کاربر بتواند بازدیدهای
|
||||
«هماهنگشده» را از «هماهنگنشده» جدا ببیند.
|
||||
|
||||
---
|
||||
|
||||
## ۱. صورت مسئله
|
||||
|
||||
پیش از این تغییر، فقط دو حالت مشتری را از فهرست خارج میکرد:
|
||||
|
||||
1. اقدام `bazdid` با وضعیت `waite` و تاریخ آینده (بازدید برنامهریزیشده)
|
||||
2. اقدام `hamahangi` با تاریخ **آینده** در فیلد تاریخ اقدام
|
||||
|
||||
اما سناریوی واقعی کارشناس این بود:
|
||||
|
||||
> «همین حالا با مشتری تماس گرفتم، توافق کردیم هفتهٔ بعد بیاییم، و تیک *انجام شد* را
|
||||
> زدم.»
|
||||
|
||||
در این حالت، فیلد «تاریخ اقدام» روی **همین حالا** مینشیند، پس هیچ قرار آیندهای
|
||||
وجود نداشت و مشتری در فهرست میماند و کارشناس دیگری دوباره تماس میگرفت.
|
||||
|
||||
**ریشه:** «تاریخ تماس» و «تاریخ قرار بعدی» دو مفهوم متفاوتاند ولی در یک فیلد
|
||||
(`actionDateTime`) جمع شده بودند.
|
||||
|
||||
---
|
||||
|
||||
## ۲. راهحل: ستون «قرار بعدی»
|
||||
|
||||
دو ستون به مدل `Customer` اضافه شد:
|
||||
|
||||
| ستون | نوع | معنا |
|
||||
|---|---|---|
|
||||
| `visit_hold_until` | `DateTimeField(null=True)` | تا **پایان** این روز پیگیری لازم نیست |
|
||||
| `visit_hold_set_at` | `DateTimeField(null=True)` | زمان ثبت، برای حسابرسی |
|
||||
|
||||
### قاعدهٔ طلایی: ستون، «مرجع» نیست
|
||||
|
||||
> `visit_hold_until` هیچ اعتباری بهتنهایی ندارد. فقط وقتی اثر دارد که برای همان
|
||||
> مشتری یک اقدام **هماهنگیِ فعال** (`waite` یا `finish`) وجود داشته باشد.
|
||||
|
||||
نتیجهٔ این قاعده:
|
||||
|
||||
| رویداد | اثر روی قرار |
|
||||
|---|---|
|
||||
| حذف اقدام هماهنگی | قرار **خودکار بیاثر** میشود |
|
||||
| لغو هماهنگی (`cancell`) | بیاثر میشود |
|
||||
| ویرایش تاریخ هماهنگی | قرار با تاریخ جدید همراستا میشود |
|
||||
| کهنه ماندن ستون | **هیچ اثر مخربی ندارد** |
|
||||
|
||||
به همین دلیل دیگر خطر «کش کهنه» وجود ندارد: منبع حقیقت همیشه جدول `Action` است.
|
||||
|
||||
---
|
||||
|
||||
## ۳. سه منبع «قرار بعدی»
|
||||
|
||||
تصمیم «مشتری در فهرست هست یا نه» از اجتماع سه منبع گرفته میشود:
|
||||
|
||||
| منبع | تعریف | تاریخ قرار |
|
||||
|---|---|---|
|
||||
| `manual` | ستون `visit_hold_until` (با اعتبارسنجی بالا) | همان ستون (پایان روز) |
|
||||
| `scheduled` | `bazdid + waite` با تاریخ آینده | `actionDateTime` |
|
||||
| `coordination` | `hamahangi` با `waite` یا `finish` و تاریخ آینده | `actionDateTime` (یا `finishDateTime`) |
|
||||
|
||||
تابع واحد: `visit_policy.next_appointment_map(now)` — **همان تابعی که اسکنر پیامک
|
||||
هم استفاده میکند**، پس صفحه و پیامک هرگز واگرا نمیشوند.
|
||||
|
||||
اگر مشتری چند قرار داشته باشد، **زودترین** تاریخ ملاک نمایش است؛ اما برای
|
||||
«بازگشت به فهرست» کافی است **آخرین** قرار منقضی شود.
|
||||
|
||||
---
|
||||
|
||||
## ۴. فرم ثبت اقدام (UI)
|
||||
|
||||
در مودال «ایجاد وظیفه» (`customerActionInc.html`):
|
||||
|
||||
| مورد | رفتار |
|
||||
|---|---|
|
||||
| نمایش فیلد | فقط وقتی نوع اقدام = **هماهنگی** انتخاب شود |
|
||||
| برچسب | «تا تاریخ پیگیری نشود» |
|
||||
| کنترل | تقویم شمسی (همان `pDatepicker` پروژه) |
|
||||
| پیشفرض | اگر خالی بماند و تیک «انجام شد» زده شود ⇒ **+۱۴ روز** |
|
||||
| سقف | **۶ ماه** (اعتبارسنجی سروری) |
|
||||
| حالت ویرایش | فیلد با نوع اقدام همراستا میشود (نمایش/پنهان) |
|
||||
|
||||
نکتهٔ مهم: فیلد «تاریخ و ساعتی که کار باید انجام شود» (`dateTimeAnjam`) همچنان
|
||||
«زمان تماس» است و دست نخورده باقی میماند. دو مفهوم، دو فیلد.
|
||||
|
||||
### ۴.۱ چیدمان مودال (mobile first)
|
||||
|
||||
مودال به سه گام شمارهدار تقسیم شده تا کاربر بداند چه چیزی را کجا وارد کند:
|
||||
|
||||
| گام | محتوا |
|
||||
|---|---|
|
||||
| ۱ | نوع اقدام + مسئول (توسط) — کنار هم در دسکتاپ، زیر هم در موبایل |
|
||||
| ۲ | زمانبندی: تاریخ انجام، «تا تاریخ پیگیری نشود»، و راهنمای بازشونده |
|
||||
| ۳ | توضیحات، تیک «انجام شده»، تاریخ اتمام و نتیجه |
|
||||
|
||||
نکات پیادهسازی:
|
||||
|
||||
* پیشوند همهٔ کلاسها `ax-` و همهٔ انتخابگرها زیر `#ActionBox` است ⇒ هیچ استایلی
|
||||
به سایر صفحات نشت نمیکند.
|
||||
* روی موبایل: کارت تمامعرض (`calc(100% - 1rem)`)، بدنهٔ اسکرولشونده، هدف لمسی
|
||||
حداقل ۲٫۶rem، و دکمههای پاصفحه تمامعرض.
|
||||
* روی دسکتاپ (≥۷۶۸px): شبکهٔ دوستونه، عرض حداکثر ۴۴rem.
|
||||
* سرصفحه نام مشتری را نشان میدهد تا کاربر بداند برای چه کسی وظیفه میسازد.
|
||||
* **همان تقویم شمسی قبلی** استفاده میشود: `dateTimePickerAnjam`،
|
||||
`dateTimePickerFinish` و `dateTimePickerHoldUntil` با `pDatepicker` و
|
||||
`calendar.persian.locale = 'fa'`. هیچ کتابخانهٔ جدیدی اضافه نشده است.
|
||||
* **نام کلاس فیلد باید با سلکتور JS یکی باشد.** هر فیلد تاریخ دو المان دارد:
|
||||
ورودی نمایشی (با کلاس `dateTimePickerXxx`) و ورودی مخفیِ ارسال (با کلاس
|
||||
`dateTimePickerXxx-alt`). اگر این نامها همخوانی نداشته باشند،
|
||||
`pDatepicker` روی مجموعهٔ خالی اجرا میشود، `undefined` برمیگرداند و
|
||||
`setDate()` با `TypeError` کل هندلر را میشکند — نتیجه: تقویم باز نمیشود و
|
||||
بقیهٔ منطق (پنهانکردن فیلدها، برداشتن تیک خودکار) هم اجرا نمیشود.
|
||||
دو تست اختصاصی
|
||||
(`test_every_picker_selector_matches_markup` و
|
||||
`test_every_alt_field_class_exists_in_markup`) همین را قفل کردهاند.
|
||||
* `altField` با فرمت پیشفرض `unix` مقدار میدهد؛ بنابراین `visitHoldUntil`
|
||||
یک timestamp میلیثانیهای است که `_parse_form_datetime` آن را میخواند.
|
||||
* تقویم به `body` اضافه میشود (نه داخل مودال) ⇒ اسکرول بدنهٔ مودال آن را نمیبرد.
|
||||
|
||||
### ۴.۲ هشدار زمانبندی و پیشفرض «انجام شده» (فقط هماهنگی)
|
||||
|
||||
چون در عمل «ثبت هماهنگی» یعنی «همین حالا با مشتری تماس گرفتم»، انتخاب نوع
|
||||
**هماهنگی** بهطور خودکار تیک «این کار انجام شده است» را میزند (و فیلدهای
|
||||
تاریخ اتمام و نتیجه را نمایش میدهد). برای جلوگیری از ثبتِ ناخواسته، دو لایه
|
||||
محافظ اضافه شده است:
|
||||
|
||||
**۱) هشدار همیشگی** — بهمحض تیکخوردن و تا وقتی نوع هماهنگی است، یک کادر
|
||||
کهربایی زیر تیک نمایش داده میشود:
|
||||
|
||||
> آیا مطمئن هستی که هماهنگی را انجام دادی؟
|
||||
> اگر هنوز هماهنگی نکردی، این تیک را بردار.
|
||||
|
||||
**۲) تأیید در لحظهٔ ثبت** — اگر کاربر روی «ثبت» بزند و نوع هماهنگی و تیک
|
||||
«انجام شده» فعال باشد، ارسال متوقف میشود و همان کادر برجسته شده و دو دکمه
|
||||
نشان میدهد:
|
||||
|
||||
| دکمه | نتیجه |
|
||||
|---|---|
|
||||
| بله، هماهنگی انجام شد | فرم ارسال میشود (هماهنگی با وضعیت `finish` ثبت میشود) |
|
||||
| نه، تیک را بردار | تیک برداشته میشود، فیلدهای اتمام پنهان میشوند و ارسال نمیشود |
|
||||
|
||||
قواعد ایمنی:
|
||||
|
||||
* **فقط هماهنگی**: سایر انواع اقدام (بازدید، نصب، وصول، تماس…) هیچ تیک
|
||||
خودکار و هیچ تأییدی ندارند و رفتارشان دقیقاً مثل قبل است.
|
||||
* **تغییر نوع = برداشتن تیک خودکار**: اگر سیستم تیک زده باشد و کاربر نوع را
|
||||
به غیر هماهنگی تغییر دهد، تیک برداشته میشود (تا ناخواسته `last_bazdid_dt`
|
||||
یا وضعیت «انجام شده» ثبت نشود). اگر کاربر خودش تیک زده باشد، دستنخورده
|
||||
میماند.
|
||||
* **حالت ویرایش**: تأیید گرفته نمیشود (کاربر فقط رکورد موجود را اصلاح میکند)؛
|
||||
اما کادر هشدار برای هماهنگیِ انجامشده بهصورت اطلاعاتی نمایش داده میشود.
|
||||
* **باز شدن مجدد مودال**: تیک، هشدار و وضعیت تأیید همیشه ریست میشوند.
|
||||
|
||||
### ۴.۳ راهنمای بازشونده (فقط «هماهنگی»)
|
||||
|
||||
زیر فیلد تاریخ پیگیری، یک دکمهٔ راهنما با آیکون `!` قرار دارد که پنل توضیحات را
|
||||
باز/بسته میکند. محتوای پنل:
|
||||
|
||||
* «این تاریخ چه کاری میکند؟» — تعریف ساده + اینکه تا پایان همان روز معتبر است.
|
||||
* «مثال» — سناریوی واقعی با تاریخ نمونه.
|
||||
* «نکات مهم» — خالی + تیک ⇒ ۱۴ روز، خالی + بدون تیک ⇒ بدون تعویق،
|
||||
فقط تمدید (نه کوتاهکردن)، حذف/لغو هماهنگی ⇒ بازگشت فوری، سقف ۶ ماه،
|
||||
تفاوت با «تاریخ بازدید پرسنل»، و توقف پیامک یادآوری در مدت تعویق.
|
||||
|
||||
رفتار:
|
||||
|
||||
| رویداد | نتیجه |
|
||||
|---|---|
|
||||
| انتخاب نوع = هماهنگی | دکمهٔ راهنما ظاهر میشود (پنل بسته) |
|
||||
| کلیک روی دکمه | پنل باز/بسته میشود و `aria-expanded` بهروز میشود |
|
||||
| تغییر نوع به غیر هماهنگی | فیلد و راهنما پنهان و پنل بسته میشود |
|
||||
| باز شدن مجدد مودال (ایجاد یا ویرایش) | پنل همیشه بسته شروع میشود |
|
||||
|
||||
دسترسیپذیری: دکمه `aria-expanded` و `aria-controls="holdHelpPanel"` دارد و
|
||||
پنل با `is-hidden` کنترل میشود.
|
||||
|
||||
### قواعد اعتبارسنجی (سرور)
|
||||
|
||||
* تاریخ قرار نباید **پیش از امروز** باشد ⇒ پیام: «تاریخ قرار نمیتواند پیش از امروز باشد.»
|
||||
* حداکثر **۱۸۳ روز** (≈۶ ماه) جلوتر ⇒ پیام: «قرار بعدی حداکثر میتواند ۱۸۳ روز (۶ ماه) جلوتر باشد.»
|
||||
* تاریخ «امروز» پذیرفته است و به **۲۳:۵۹:۵۹ همان روز** تبدیل میشود (تا پایان روز معتبر).
|
||||
* اگر اعتبارسنجی رد شود، **هیچ اقدامی ذخیره نمیشود**.
|
||||
|
||||
### قاعدهٔ «تمدید مجاز، کوتاهکردن ممنوع» (max)
|
||||
|
||||
قرار ذخیرهشده همیشه **بزرگترین** تاریخِ ثبتشده است:
|
||||
|
||||
```
|
||||
visit_hold_until = max(مقدار فعلی، تاریخ جدید)
|
||||
```
|
||||
|
||||
| وضعیت | نتیجه |
|
||||
|---|---|
|
||||
| قرار فعلی ۱ آذر، کارشناس جدید ۸ آذر | **تمدید میشود به ۸ آذر** ✅ |
|
||||
| قرار فعلی ۸ آذر، کارشناس جدید ۱ آذر | **تغییری نمیکند** (۸ آذر میماند) |
|
||||
|
||||
منطق: اگر مشتری گفته «۸ آذر تماس بگیرید»، سیستم نباید ۱ آذر زنگ بزند. در مقابل،
|
||||
یک ثبت اشتباه نباید قرارِ طولانیِ قبلی را نابود کند.
|
||||
|
||||
**برای عقببردن قرار** (مثلاً مشتری گفت زودتر بیایید): هماهنگی را **حذف** و دوباره
|
||||
با تاریخ جدید ثبت کنید.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ۵. صفحهٔ انتظار: چهار گروه و فیلترها
|
||||
|
||||
هر مشتری معوق **دقیقاً در یکی** از این چهار گروه است:
|
||||
|
||||
| گروه | تعریف |
|
||||
|---|---|
|
||||
| **A** | معوق، بدون هیچ قرار |
|
||||
| **B** | معوق، فقط قرار دستی (`manual`) |
|
||||
| **C** | معوق، فقط بازدید برنامهریزیشده یا هماهنگی در انتظار |
|
||||
| **D** | معوق، هم قرار دستی و هم بازدید برنامهریزیشده |
|
||||
|
||||
### فیلترها (قابل بوکمارک و ترکیب با جستوجو)
|
||||
|
||||
| URL | نمایش | شامل |
|
||||
|---|---|---|
|
||||
| `/panel/entezar/` (بدون پارامتر) | **پیشفرض = فقط A** (رفتار قبلی صفحه) | A |
|
||||
| `?view=uncoordinated` | بدون قرار | A |
|
||||
| `?view=manual` | قرار هماهنگی | B + D |
|
||||
| `?view=scheduled` | بازدید برنامهریزیشده | C + D |
|
||||
| `?view=coordinated` | همهٔ دارای قرار | B + C + D |
|
||||
| `?view=all` | همهٔ معوقها | A + B + C + D |
|
||||
| مقدار نامعتبر | برگشت به پیشفرض، **بدون خطا** | A |
|
||||
|
||||
### ستونهای جدول
|
||||
|
||||
| ستون | توضیح |
|
||||
|---|---|
|
||||
| وضعیت قرار | برچسب «با قرار» / «بدون قرار» |
|
||||
| قرار هماهنگی | تاریخ شمسی قرار دستی (یا —) |
|
||||
| بازدید برنامهریزیشده | تاریخ شمسی قرار `Action` (یا —) |
|
||||
| آخرین بازدید | بدون تغییر نسبت به قبل |
|
||||
|
||||
### شمارندهٔ داشبورد
|
||||
|
||||
`CacheTable('9mahbazdid')` = **گروه A** (کارِ فوری = بدون قرار). هر بار باز شدن
|
||||
صفحه بهروز میشود.
|
||||
|
||||
---
|
||||
|
||||
## ۶. تعامل با سایر بخشها
|
||||
|
||||
| بخش | اثر |
|
||||
|---|---|
|
||||
| **آخرین بازدید** (`last_visit_map`) | دستنخورده — فقط `bazdid + finish` |
|
||||
| **چرخهٔ ۹ ماهه** (`cycle_anchor_map`) | دستنخورده — `bazdid` و `nasb` |
|
||||
| **اسکنر پیامک** (`scan_visit`) | فقط یک منبع اضافه در `next_appointment_map` |
|
||||
| **`recalc_last_bazdid`** | دستنخورده |
|
||||
| **`last_bazdid_dt`** | بدون تغییر (نمایش/ترتیب) |
|
||||
| **ایندکس `act_visit_idx`** | بدون تغییر |
|
||||
|
||||
قاعدهٔ طلایی حفظ شده است: **هماهنگی هرگز بازدید حساب نمیشود.**
|
||||
|
||||
---
|
||||
|
||||
## ۷. سناریوهای تستشده
|
||||
|
||||
| # | سناریو | نتیجهٔ انتظار | وضعیت |
|
||||
|---|---|---|---|
|
||||
| ۱ | هماهنگی با قرار «امروز» | تا پایان روز خارج از فهرست | ✅ |
|
||||
| ۲ | هماهنگی finish بدون تاریخ قرار | پیشفرض ۱۴ روز | ✅ |
|
||||
| ۳ | حذف اقدام هماهنگی | قرار بیاثر، مشتری برمیگردد | ✅ |
|
||||
| ۴ | لغو هماهنگی (`cancell`) | قرار بیاثر | ✅ |
|
||||
| ۵ | هماهنگی دوم با تاریخ کوچکتر | قرار قبلی حفظ میشود | ✅ |
|
||||
| ۶ | هماهنگی دوم با تاریخ بزرگتر | قرار تمدید میشود | ✅ |
|
||||
| ۷ | قرار بیش از ۶ ماه | رد با پیام، هیچ ذخیرهای | ✅ |
|
||||
| ۸ | قرار در گذشته | رد با پیام | ✅ |
|
||||
| ۹ | بازدید واقعی بعد از قرار | مشتری از فهرست خارج میماند | ✅ |
|
||||
| ۱۰ | هماهنگی finish با تاریخ آینده (بدون ستون) | خارج از فهرست | ✅ |
|
||||
| ۱۱ | تفکیک A/B/C/D و هر پنج فیلتر | اعداد و فهرست درست | ✅ |
|
||||
| ۱۲ | `?view=nonsense` | پیشفرض، بدون خطا | ✅ |
|
||||
| ۱۳ | اسکنر پیامک با قرار دستی | پیامک ارسال نمیشود | ✅ |
|
||||
| ۱۴ | اجتماع منابع در `next_appointment_map` | زودترین تاریخ | ✅ |
|
||||
|
||||
جمع: **۳۷۶ تست موفق** در کل پروژه (۱۵ تست جدید مربوط به این قابلیت).
|
||||
|
||||
---
|
||||
|
||||
## ۸. استقرار
|
||||
|
||||
```bash
|
||||
# ۱) بکاپ
|
||||
python manage.py sqlite_db_backup # یا کپی فایل دیتابیس
|
||||
|
||||
# ۲) مهاجرت (دو ستون nullable — امن و سریع)
|
||||
python manage.py migrate
|
||||
|
||||
# ۳) بررسی
|
||||
python manage.py check
|
||||
python manage.py test tests
|
||||
```
|
||||
|
||||
### رفتار دادهٔ موجود
|
||||
|
||||
* همهٔ مشتریان با `visit_hold_until = NULL` شروع میکنند ⇒ **رفتار امروز دقیقاً حفظ میشود**.
|
||||
* هماهنگیهای قدیمی (۵ رکورد سال ۲۰۲۲) هیچ ستونی ندارند و فقط از مسیر `Action`
|
||||
ارزیابی میشوند.
|
||||
|
||||
---
|
||||
|
||||
## ۹. نکات عملیاتی
|
||||
|
||||
1. **حذف هماهنگی** بهترین راه برای «لغو قرار» است.
|
||||
2. اگر قرار اشتباه ثبت شد، هماهنگی را **حذف** و دوباره ثبت کنید (قرار جدید فقط
|
||||
میتواند تمدید کند، نه آن را کوتاهتر کند).
|
||||
|
||||
---
|
||||
|
||||
# بخش دوم — امکانات جدید رابط کاربری صفحهٔ انتظار
|
||||
|
||||
این بخش قابلیتهایی را توضیح میدهد که برای «راحت دیدن اطلاعات و تصمیم درست
|
||||
گرفتن» اضافه شدهاند. طراحی **mobile first** است: در موبایل هر مشتری یک کارت
|
||||
میشود و در دسکتاپ همان جدول قبلی؛ هیچ صفحه/منوی جدیدی اضافه نشده و شکل کلی
|
||||
صفحه حفظ شده است.
|
||||
|
||||
## ۱. نوار آمار (بالای صفحه)
|
||||
|
||||
پنج کارت که هر کدام هم **عدد** را نشان میدهد و هم **لینک فیلتر** است:
|
||||
|
||||
| کارت | چه چیزی را نشان میدهد |
|
||||
|---|---|
|
||||
| کل معوق | همهٔ مشتریان عقبافتاده (با قرار و بدون قرار) |
|
||||
| بدون قرار | کسانی که هیچ قراری ندارند — **کار عملیاتی امروز** |
|
||||
| با قرار | قرار هماهنگی یا بازدید برنامهریزیشده دارند |
|
||||
| قرار هماهنگی | قرار دستی ثبتشده روی مشتری |
|
||||
| بازدید برنامهریزیشده | بازدید/هماهنگیِ در جریان از نوع `Action` |
|
||||
|
||||
کارت فعال با حاشیهٔ آبی مشخص میشود.
|
||||
|
||||
## ۲. تفکیک مدت تأخیر
|
||||
|
||||
زیر نوار آمار، تعداد مشتریان بر اساس **مدت عقبافتادگی** نمایش داده میشود:
|
||||
|
||||
- کمتر از ۳۰ روز
|
||||
- ۳۰ تا ۹۰ روز
|
||||
- ۹۰ تا ۱۸۰ روز
|
||||
- بیش از ۶ ماه
|
||||
|
||||
**مبنای محاسبه:** «موعد بازدید» = آخرین بازدید معتبر + دورهٔ قالب (پیشفرض ۹ ماه).
|
||||
برای مشتریِ بدون بازدید، مبنای مقایسه مقطع دوره است تا عدد بیمعنا نشود.
|
||||
یعنی «۳ ماه تأخیر» یعنی «۳ ماه از موعد بازدید گذشته»، نه «۳ ماه از خود بازدید».
|
||||
|
||||
## ۳. مرتبسازی
|
||||
|
||||
سه حالت اختیاری (پارامتر `sort` در URL، قابل بوکمارک):
|
||||
|
||||
| مقدار | ترتیب | کاربرد |
|
||||
|---|---|---|
|
||||
| `oldest` (پیشفرض) | قدیمیترین بازدید اول | اولویت کارشناس |
|
||||
| `overdue` | بیشترین تأخیر اول | ببینیم چه کسی از همه دیرتر است |
|
||||
| `newest` | جدیدترین بازدید اول | بررسی موارد تازهمعوق |
|
||||
|
||||
ترتیب بر مبنای **تاریخ مؤثر بازدید از `Action`** است، نه ستون
|
||||
`last_bazdid_dt` (که ممکن است کهنه باشد). برای گروهِ بدون بازدید، مبنای
|
||||
ترتیب مقطع دوره است.
|
||||
|
||||
## ۴. جستوجو (تکمیلشده)
|
||||
|
||||
پیشتر فقط جستوجو بر اساس **آدرس** ممکن بود. اکنون:
|
||||
|
||||
| فیلد | جستوجو در |
|
||||
|---|---|
|
||||
| نام و نام خانوادگی | `name_family` |
|
||||
| تلفن | `phone1`، `phone2`، `phone3` |
|
||||
| آدرس | `address` |
|
||||
| کد مشتری | `idCode` |
|
||||
|
||||
نما و مرتبسازی هنگام جستوجو **حفظ میشوند** و دکمهٔ «حذف جستجو» برای برگشت
|
||||
به فهرست کامل هست.
|
||||
|
||||
## ۵. چیدمان ستونها (بازطراحی)
|
||||
|
||||
پیشتر ستون «ردیف» یک ستون کامل جدول را برای یک عدد دو رقمی میگرفت و دو ستون
|
||||
با `min-width` ثابت (۱۹۰px و ۳۲۰px) تعیین عرض میکردند؛ نتیجه: بیتناسبی
|
||||
فاصلهها و اسکرول افقی. تغییرات:
|
||||
|
||||
1. **ستون «ردیف» حذف شد.** شمارهٔ ردیف حالا یک **چیپ کوچک کنار نام** است
|
||||
(`.ez-idx`) که هم فضا نمیخورد و هم برای پیدا کردن ردیف کافی است.
|
||||
2. **عرض ستونها با `<colgroup>` درصدی** تعیین میشود و جدول
|
||||
`table-layout: fixed` است. بنابراین:
|
||||
- مجموع عرضها همیشه ۱۰۰٪ است،
|
||||
- هیچ ستونی بیدلیل پهن نمیشود،
|
||||
- اسکرول افقی در حالت جدول رخ نمیدهد.
|
||||
3. **مقیاس فاصله یکسان:** همهٔ حاشیهها مضرب ۰٫۲۵rem (۴px) هستند.
|
||||
4. **سرستون چسبان (sticky)** هنگام اسکرول عمودی.
|
||||
5. **اعداد همعرض** (`tabular-nums`) تا ستون تاریخها لق نزند.
|
||||
6. **تلفن با `direction: ltr`** تا ارقام در متن راستبهچپ جابهجا نشوند.
|
||||
7. **آدرس حداکثر دو خط** (`line-clamp`) با متن کامل در `title`.
|
||||
8. **تأخیر بهشکل چیپ رنگی** (خاکستری/کهربایی/قرمز/تیره) برای اسکن سریع.
|
||||
|
||||
| ستون | عرض | محتوا |
|
||||
|---|---|---|
|
||||
| نام و نام خانوادگی | ۲۳٪ | چیپ شماره + نام + کد مشتری |
|
||||
| وضعیت قرار | ۱۳٪ | برچسب رنگی وضعیت |
|
||||
| قرار هماهنگی | ۱۰٪ | تاریخ شمسی قرار دستی |
|
||||
| بازدید برنامهریزیشده | ۱۱٪ | تاریخ قرار از `Action` |
|
||||
| آخرین بازدید | ۱۰٪ | تاریخ مؤثر واقعی |
|
||||
| تأخیر | ۹٪ | چیپ تعداد روز |
|
||||
| تلفن | ۱۱٪ | شمارهٔ اول (LTR) |
|
||||
| آدرس | ۱۳٪ | دو خط + `title` |
|
||||
|
||||
**نقطهٔ شکست (breakpoint):** تا **۸۹۹px** حالت «کارت» و از **۹۰۰px** حالت
|
||||
«جدول». این دو مرز باید همیشه یکی باشند؛ اگر نباشند، در بازهای از عرض صفحه هر
|
||||
دو حالت همزمان اعمال میشوند و چیدمان میشکند. یک تست اختصاصی
|
||||
(`test_card_and_table_breakpoints_agree`) همین را قفل کرده است.
|
||||
|
||||
## ۶. قابلیتهای موبایل
|
||||
|
||||
- هر ردیف یک **کارت** میشود. نام مشتری سرِ کارت است (بدون برچسب) و بقیهٔ
|
||||
مقادیر با برچسب ثابت ۶٫۵rem همتراز میشوند.
|
||||
- برچسب «وضعیت» حذف میشود چون خودِ برچسب رنگی گویاست.
|
||||
- دکمهٔ **«تماس»** (`tel:`) و **«ثبت اقدام»** بهصورت دو دکمهٔ تمامعرض.
|
||||
- شمارههای دوم و سوم در موبایل مخفیاند (برای تماس، شمارهٔ اول کافی است).
|
||||
|
||||
## ۷. نمای خالی
|
||||
|
||||
اگر در نما/جستوجوی جاری موردی نباشد، پیام «موردی در این نما پیدا نشد»
|
||||
نمایش داده میشود (بهجای جدولِ خالی و گمراهکننده).
|
||||
|
||||
## ۸. کارایی
|
||||
|
||||
| سنجه | مقدار |
|
||||
|---|---|
|
||||
| تعداد کوئری در بارگذاری صفحه | **۱۹** |
|
||||
| رندر کامل صفحه روی دادهٔ واقعی (۳٬۲۴۳ معوق) | ~۱ ثانیه |
|
||||
|
||||
نکتهٔ پیادهسازی: دورهٔ قالب **یک بار** خوانده میشود و به حلقهٔ محاسبهٔ تأخیر
|
||||
پاس داده میشود (`months=period_months`). اگر این پارامتر پاس نشود، برای هر
|
||||
مشتری یک کوئری اضافه زده میشود (باگ N+1 که در تستها کشف و رفع شد).
|
||||
|
||||
## ۹. آنچه تغییر نکرد
|
||||
|
||||
- منطق «چه کسی عقبافتاده است» (همان `last_visit_map` + `due_cut`).
|
||||
- قاعدهٔ اعتبار قرار دستی (وجود هماهنگی فعال).
|
||||
- اسکنر پیامک، `recalc_last_bazdid` و چرخهٔ ۹ ماهه.
|
||||
- لینکهای قدیمی `?view=...` و رفتار پیشفرض (نمای «بدون قرار»).
|
||||
|
||||
## ۱۰. پارامترهای URL
|
||||
|
||||
```
|
||||
/panel/entezar/ نمای پیشفرض (بدون قرار، قدیمیترین)
|
||||
?view=all همهٔ معوقها
|
||||
?view=coordinated با قرار
|
||||
?view=manual فقط قرار هماهنگی
|
||||
?view=scheduled فقط بازدید برنامهریزیشده
|
||||
?sort=overdue بیشترین تأخیر اول
|
||||
?sort=newest جدیدترین بازدید اول
|
||||
?searchVal=رضایی&searchOn=name جستوجوی نام
|
||||
?searchVal=0912&searchOn=phone جستوجوی تلفن
|
||||
?view=all&sort=overdue&page=3 ترکیبپذیر
|
||||
```
|
||||
|
||||
مقدار نامعتبر `view` یا `sort` بیصدا به پیشفرض برمیگردد (هیچ خطای ۴۰۰/۵۰۰).
|
||||
|
||||
3. پس از تاریخ قرار، مشتری **خودکار** در گروه A ظاهر میشود؛ نیازی به کار دستی نیست.
|
||||
4. عدد «بدون قرار» در داشبورد همان لیست کاریِ فوری است.
|
||||
|
||||
|
||||
|
||||
@@ -305,6 +305,8 @@ class ApplyTests(CommandTestMixin, TestCase):
|
||||
"id", "name_family", "address", "notes", "phone1", "phone2",
|
||||
"phone3", "idCode", "installDate", "create_date", "last_bazdid_dt",
|
||||
"laghv_bazdid", "is_active", "sms_primary_phones",
|
||||
# «قرار بعدی» (تعویق بازدید از فرم هماهنگی) + زمان ثبت برای حسابرسی.
|
||||
"visit_hold_until", "visit_hold_set_at",
|
||||
})
|
||||
self.assertNotIn("gender", field_names)
|
||||
|
||||
|
||||
+563
-41
@@ -9,20 +9,24 @@
|
||||
* بازدید انجامشده با تاریخ بیش از ۳۰ روز آینده در لایهٔ HTTP رد میشود.
|
||||
"""
|
||||
import datetime
|
||||
import re
|
||||
import io
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.management import CommandError, call_command
|
||||
from django.db import connection
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from admin_panel.models import Action, Customer
|
||||
from admin_panel.models import Action, Customer, PhoneNumber
|
||||
from admin_panel.visit_policy import (active_deferral, as_aware, last_visit_dt,
|
||||
last_visit_map, refresh_last_bazdid,
|
||||
last_visit_map, normalize_hold_until,
|
||||
refresh_last_bazdid, set_visit_hold,
|
||||
validate_action_payload,
|
||||
visit_cycle_index, visit_due_date)
|
||||
|
||||
@@ -761,56 +765,574 @@ class NextAppointmentEquivalenceTests(TestCase):
|
||||
self.assertNotIn(customer_c.pk, result)
|
||||
|
||||
|
||||
class VisitHoldTests(TestCase):
|
||||
"""«قرار بعدی» (تعویق از فرم هماهنگی): هستهٔ رفع باگ + ریسکهای شناساییشده."""
|
||||
|
||||
def setUp(self):
|
||||
from tests.factories import make_user
|
||||
|
||||
self.user = make_user('holdExpert', superuser=True)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
# ---------- helpers ----------
|
||||
def _customer(self, name):
|
||||
customer = make_customer(name)
|
||||
Customer.objects.filter(pk=customer.pk).update(
|
||||
installDate=timezone.localdate() - timedelta(days=400)
|
||||
)
|
||||
customer.refresh_from_db()
|
||||
return customer
|
||||
|
||||
def _entezar(self, view=''):
|
||||
url = '/panel/entezar/' + (f'?view={view}' if view else '')
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return response
|
||||
|
||||
def _ids(self, view=''):
|
||||
return {c.id for c in self._entezar(view).context['customers']}
|
||||
|
||||
def _post_action(self, customer, **payload):
|
||||
base = {'desc': 'تست', 'userID': self.user.id}
|
||||
base.update(payload)
|
||||
for key in [k for k, v in list(base.items()) if v is None]:
|
||||
base.pop(key)
|
||||
return self.client.post(
|
||||
reverse('admin_panel:customerNewAction', kwargs={'c_id': customer.pk}), base,
|
||||
)
|
||||
|
||||
def _ms(self, moment):
|
||||
return str(int(moment.timestamp() * 1000))
|
||||
|
||||
# ---------- ریسک ۱: تاریخِ «امروز» تا پایان روز معتبر است ----------
|
||||
def test_hold_until_today_hides_customer_until_end_of_day(self):
|
||||
customer = self._customer('قرار امروز')
|
||||
self._post_action(
|
||||
customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now()),
|
||||
is_done='on', visitHoldUntil=self._ms(timezone.now()),
|
||||
)
|
||||
customer.refresh_from_db()
|
||||
self.assertIsNotNone(customer.visit_hold_until)
|
||||
local = customer.visit_hold_until.astimezone(timezone.get_current_timezone())
|
||||
self.assertEqual((local.hour, local.minute), (23, 59))
|
||||
self.assertNotIn(customer.pk, self._ids())
|
||||
|
||||
# ---------- ریسک ۲: finish بدون تاریخ قرار ⇒ پیشفرض ۱۴ روز ----------
|
||||
def test_finished_coordination_without_hold_defaults_to_two_weeks(self):
|
||||
customer = self._customer('هماهنگی بدون تاریخ قرار')
|
||||
self._post_action(
|
||||
customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now()),
|
||||
is_done='on',
|
||||
)
|
||||
customer.refresh_from_db()
|
||||
self.assertIsNotNone(customer.visit_hold_until)
|
||||
days = (customer.visit_hold_until.date() - timezone.localdate()).days
|
||||
self.assertEqual(days, 14)
|
||||
self.assertNotIn(customer.pk, self._ids())
|
||||
|
||||
# ---------- ریسک ۳: حذف/لغو هماهنگی ⇒ قرار بیاثر ----------
|
||||
def test_hold_becomes_inert_when_coordination_is_deleted(self):
|
||||
customer = self._customer('حذف هماهنگی')
|
||||
self._post_action(
|
||||
customer, actionType='hamahangi',
|
||||
dateTimeAnjam=self._ms(timezone.now() + timedelta(days=10)),
|
||||
is_done='on',
|
||||
visitHoldUntil=self._ms(timezone.now() + timedelta(days=10)),
|
||||
)
|
||||
self.assertNotIn(customer.pk, self._ids())
|
||||
Action.objects.filter(customer=customer).delete()
|
||||
customer.refresh_from_db()
|
||||
self.assertIsNotNone(customer.visit_hold_until) # ستون باقی است…
|
||||
self.assertIn(customer.pk, self._ids()) # …ولی بیاثر شده است
|
||||
|
||||
def test_hold_becomes_inert_when_coordination_is_cancelled(self):
|
||||
customer = self._customer('لغو هماهنگی')
|
||||
action = Action.objects.create(
|
||||
customer=customer, actionType='hamahangi', status='finish',
|
||||
actionDateTime=timezone.now() + timedelta(days=10),
|
||||
finishDateTime=timezone.now(),
|
||||
)
|
||||
set_visit_hold(customer, timezone.now() + timedelta(days=10))
|
||||
self.assertNotIn(customer.pk, self._ids())
|
||||
Action.objects.filter(pk=action.pk).update(status='cancell')
|
||||
self.assertIn(customer.pk, self._ids())
|
||||
|
||||
# ---------- ریسک ۴: قاعدهٔ «فقط رو به جلو» ----------
|
||||
def test_second_coordination_does_not_pull_hold_backwards(self):
|
||||
customer = self._customer('قرار قبلی دورتر')
|
||||
set_visit_hold(customer, timezone.now() + timedelta(days=30))
|
||||
written = set_visit_hold(customer, timezone.now() + timedelta(days=5))
|
||||
self.assertFalse(written)
|
||||
customer.refresh_from_db()
|
||||
self.assertEqual(
|
||||
customer.visit_hold_until.date(),
|
||||
(timezone.now() + timedelta(days=30)).date(),
|
||||
)
|
||||
|
||||
def test_second_coordination_can_extend_hold(self):
|
||||
customer = self._customer('تمدید قرار')
|
||||
set_visit_hold(customer, timezone.now() + timedelta(days=5))
|
||||
written = set_visit_hold(customer, timezone.now() + timedelta(days=20))
|
||||
self.assertTrue(written)
|
||||
customer.refresh_from_db()
|
||||
self.assertEqual(
|
||||
customer.visit_hold_until.date(),
|
||||
(timezone.now() + timedelta(days=20)).date(),
|
||||
)
|
||||
|
||||
|
||||
# ---------- سقف ۶ ماه و تاریخ گذشته (اعتبارسنجی سرور) ----------
|
||||
def test_hold_beyond_six_months_is_rejected(self):
|
||||
customer = self._customer('تعویق بلند')
|
||||
response = self._post_action(
|
||||
customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now()),
|
||||
is_done='on', visitHoldUntil=self._ms(timezone.now() + timedelta(days=200)),
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertFalse(Action.objects.filter(customer=customer).exists())
|
||||
customer.refresh_from_db()
|
||||
self.assertIsNone(customer.visit_hold_until)
|
||||
|
||||
def test_hold_in_the_past_is_rejected(self):
|
||||
with self.assertRaises(ValidationError):
|
||||
normalize_hold_until(timezone.now() - timedelta(days=3))
|
||||
|
||||
def test_hold_none_returns_none(self):
|
||||
self.assertIsNone(normalize_hold_until(None))
|
||||
|
||||
# ---------- بازدید واقعی ⇒ مشتری از فهرست خارج میشود ----------
|
||||
def test_real_visit_clears_hold_and_leaves_list(self):
|
||||
customer = self._customer('بازدید واقعی')
|
||||
Action.objects.create(customer=customer, actionType='hamahangi', status='finish',
|
||||
actionDateTime=timezone.now() + timedelta(days=10),
|
||||
finishDateTime=timezone.now())
|
||||
set_visit_hold(customer, timezone.now() + timedelta(days=10))
|
||||
self.assertNotIn(customer.pk, self._ids())
|
||||
|
||||
self._post_action(
|
||||
customer, actionType='bazdid', dateTimeAnjam=self._ms(timezone.now()),
|
||||
is_done='on', FinishDateTime=self._ms(timezone.now()), result='انجام شد',
|
||||
)
|
||||
customer.refresh_from_db()
|
||||
self.assertIsNotNone(customer.last_bazdid_dt)
|
||||
self.assertNotIn(customer.pk, self._ids('all'))
|
||||
|
||||
# ---------- هماهنگیِ finish با تاریخ آینده حتی بدون ستون، قرار میسازد ----------
|
||||
def test_coordination_without_hold_still_hides_via_action(self):
|
||||
customer = self._customer('هماهنگی بدون ستون')
|
||||
Action.objects.create(
|
||||
customer=customer, actionType='hamahangi', status='finish',
|
||||
actionDateTime=timezone.now() + timedelta(days=6),
|
||||
finishDateTime=timezone.now(),
|
||||
)
|
||||
self.assertNotIn(customer.pk, self._ids())
|
||||
|
||||
|
||||
# ---------- تفکیک چهارگروهی و فیلترها ----------
|
||||
def test_groups_and_filters(self):
|
||||
a = self._customer('الف بدون قرار')
|
||||
b = self._customer('ب قرار دستی')
|
||||
c = self._customer('ج بازدید برنامه')
|
||||
d = self._customer('د هر دو')
|
||||
|
||||
# b: قرار دستی (هماهنگی با تاریخ تماس «همین حالا» + ستون قرار ۲۰ روز بعد).
|
||||
Action.objects.create(customer=b, actionType='hamahangi', status='finish',
|
||||
actionDateTime=timezone.now(), finishDateTime=timezone.now())
|
||||
set_visit_hold(b, timezone.now() + timedelta(days=20))
|
||||
|
||||
Action.objects.create(customer=c, actionType='bazdid', status='waite',
|
||||
actionDateTime=timezone.now() + timedelta(days=7))
|
||||
|
||||
# d: هم قرار دستی و هم بازدید برنامهریزیشده.
|
||||
Action.objects.create(customer=d, actionType='hamahangi', status='finish',
|
||||
actionDateTime=timezone.now(), finishDateTime=timezone.now())
|
||||
set_visit_hold(d, timezone.now() + timedelta(days=25))
|
||||
Action.objects.create(customer=d, actionType='bazdid', status='waite',
|
||||
actionDateTime=timezone.now() + timedelta(days=9))
|
||||
|
||||
counts = self._entezar().context['counts']
|
||||
self.assertEqual(counts['uncoordinated'], 1) # a
|
||||
self.assertEqual(counts['manual'], 1) # b
|
||||
self.assertEqual(counts['scheduled'], 1) # c
|
||||
self.assertEqual(counts['both'], 1) # d
|
||||
self.assertEqual(counts['total'], 4)
|
||||
self.assertEqual(counts['coordinated'], 3)
|
||||
|
||||
self.assertEqual(self._ids(), {a.pk})
|
||||
self.assertEqual(self._ids('all'), {a.pk, b.pk, c.pk, d.pk})
|
||||
self.assertEqual(self._ids('manual'), {b.pk, d.pk})
|
||||
self.assertEqual(self._ids('scheduled'), {c.pk, d.pk})
|
||||
self.assertEqual(self._ids('coordinated'), {b.pk, c.pk, d.pk})
|
||||
|
||||
def test_invalid_view_falls_back_to_default(self):
|
||||
a = self._customer('فقط الف')
|
||||
self.assertEqual(self._ids('nonsense'), {a.pk})
|
||||
self.assertEqual(self._entezar('nonsense').context['view'], 'uncoordinated')
|
||||
|
||||
# ---------- اسکنر پیامک هم قرار دستی را میبیند ----------
|
||||
def test_scanner_skips_customer_with_manual_hold(self):
|
||||
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
|
||||
from admin_panel.sms.scanner import scan_visit
|
||||
|
||||
customer = self._customer('قرار + اسکنر')
|
||||
PhoneNumber.objects.create(customer=customer, phone_number='09120000555')
|
||||
config = SMSProviderConfig.load()
|
||||
config.allow_backlog = True
|
||||
config.backlog_max = 50
|
||||
config.scan_since = timezone.now() - timedelta(days=2000)
|
||||
config.save()
|
||||
|
||||
Action.objects.create(customer=customer, actionType='hamahangi', status='finish',
|
||||
actionDateTime=timezone.now() + timedelta(days=15),
|
||||
finishDateTime=timezone.now())
|
||||
set_visit_hold(customer, timezone.now() + timedelta(days=15))
|
||||
|
||||
scan_visit(timezone.localdate())
|
||||
self.assertFalse(SMSQueue.objects.filter(customer=customer).exists())
|
||||
|
||||
# ---------- اجتماع منابع ----------
|
||||
def test_next_appointment_is_union_of_sources(self):
|
||||
from admin_panel.visit_policy import next_appointment_map
|
||||
|
||||
customer = self._customer('اجتماع منابع')
|
||||
now = timezone.now()
|
||||
Action.objects.create(customer=customer, actionType='hamahangi', status='finish',
|
||||
actionDateTime=now + timedelta(days=30),
|
||||
finishDateTime=now)
|
||||
set_visit_hold(customer, now + timedelta(days=20))
|
||||
merged = next_appointment_map(now=now)
|
||||
self.assertIn(customer.pk, merged)
|
||||
# قرار دستی «پایان روز» است، پس مقایسه در سطح تاریخ انجام میشود.
|
||||
self.assertLessEqual(merged[customer.pk].date(), (now + timedelta(days=20)).date())
|
||||
|
||||
|
||||
class VisitDedupCleanupTests(TestCase):
|
||||
"""پاکسازی کلیدهای آلودهٔ دفتر یکتاسازی (بدون لمس دادهٔ مشتری)."""
|
||||
|
||||
def _keys(self):
|
||||
from admin_panel.models import SMSDedup
|
||||
return set(SMSDedup.objects.values_list('key', flat=True))
|
||||
class ActionModalTemplateTests(TestCase):
|
||||
"""قرارداد فرم «ایجاد وظیفه» (`customerActionInc.html`).
|
||||
|
||||
def test_removes_only_absurd_keys_by_default(self):
|
||||
from admin_panel.models import SMSDedup
|
||||
این تستها تضمین میکنند بازطراحی UI، نام فیلدها/شناسههایی را که ویو
|
||||
انتظار دارد تغییر ندهد (چون ویو با ``request.POST.get(name)`` میخواند) و
|
||||
مودال با همان تقویم شمسی موجود کار کند.
|
||||
"""
|
||||
|
||||
SMSDedup.objects.bulk_create([
|
||||
SMSDedup(key='visit:10:24663'), # عدد بیمعنا (باگ تقسیم قدیمی)
|
||||
SMSDedup(key='visit:11:1'), # قدیمی ولی منطقی
|
||||
SMSDedup(key='visit:12:2026-03'), # قالب جدید
|
||||
SMSDedup(key='welcome:5'), # نوع دیگر
|
||||
])
|
||||
call_command('cleanup_visit_dedup', stdout=io.StringIO())
|
||||
self.assertEqual(
|
||||
self._keys(),
|
||||
{'visit:11:1', 'visit:12:2026-03', 'welcome:5'},
|
||||
def setUp(self):
|
||||
self.user = make_user('modal_viewer')
|
||||
self.user.user_permissions.add(*Permission.objects.filter(
|
||||
content_type__app_label='admin_panel'))
|
||||
self.client.force_login(self.user)
|
||||
self.customer = make_customer('مشتری مودال')
|
||||
self.html = self.client.get(
|
||||
reverse('admin_panel:customerAction', kwargs={'c_id': self.customer.pk})
|
||||
).content.decode()
|
||||
|
||||
def test_expected_form_fields_exist(self):
|
||||
# اینها همان کلیدهایی هستند که ویو از request.POST میخواند.
|
||||
for name in ('actionType', 'dateTimeAnjam', 'visitHoldUntil', 'desc',
|
||||
'userID', 'is_done', 'FinishDateTime', 'result'):
|
||||
self.assertIn(f'name="{name}"', self.html, f'فیلد {name} گم شده است')
|
||||
|
||||
def test_javascript_hooks_exist(self):
|
||||
# JS داخل قالب و ویوهای دیگر به این شناسهها و کلاسها وابستهاند.
|
||||
for element_id in ('actionType', 'userID', 'checkIsDone', 'sabtBtn05',
|
||||
'ActionBox', 'holdHelpToggle', 'holdHelpPanel'):
|
||||
self.assertIn(f'id="{element_id}"', self.html, f'شناسه {element_id} گم شده است')
|
||||
for cls in ('closeActionBox', 'showWhenDone', 'showWhenCoordination'):
|
||||
self.assertIn(cls, self.html, f'کلاس {cls} گم شده است')
|
||||
|
||||
def test_persian_datepickers_are_used(self):
|
||||
for cls in ('dateTimePickerAnjam', 'dateTimePickerFinish', 'dateTimePickerHoldUntil'):
|
||||
self.assertIn(cls, self.html, f'تقویم {cls} گم شده است')
|
||||
|
||||
def test_help_panel_is_collapsed_by_default(self):
|
||||
# پنل راهنما باید بسته باشد و با aria-expanded مدیریت شود.
|
||||
self.assertIn('aria-expanded="false"', self.html)
|
||||
self.assertIn('ax-help-panel is-hidden', self.html)
|
||||
self.assertIn('aria-controls="holdHelpPanel"', self.html)
|
||||
|
||||
def test_help_is_scoped_to_coordination(self):
|
||||
# راهنما و فیلد تاریخ پیگیری فقط داخل بلوک «هماهنگی» هستند.
|
||||
for marker in ('ax-help-wrap showWhenCoordination is-hidden',
|
||||
'ax-field showWhenCoordination is-hidden'):
|
||||
self.assertIn(marker, self.html, f'دامنهٔ نمایش {marker} اشتباه است')
|
||||
|
||||
def test_form_still_posts_to_new_action(self):
|
||||
self.assertIn(
|
||||
reverse('admin_panel:customerNewAction', kwargs={'c_id': self.customer.pk}),
|
||||
self.html,
|
||||
)
|
||||
|
||||
def test_all_legacy_flag_removes_numeric_keys_too(self):
|
||||
from admin_panel.models import SMSDedup
|
||||
def test_ui_classes_are_namespaced(self):
|
||||
# پیشوند ax- جلوی تداخل استایل با سایر صفحات را میگیرد.
|
||||
self.assertIn('ax-card', self.html)
|
||||
self.assertIn('ax-section', self.html)
|
||||
|
||||
SMSDedup.objects.bulk_create([
|
||||
SMSDedup(key='visit:11:1'),
|
||||
SMSDedup(key='visit:12:2026-03'),
|
||||
])
|
||||
call_command('cleanup_visit_dedup', '--all-legacy', stdout=io.StringIO())
|
||||
self.assertEqual(self._keys(), {'visit:12:2026-03'})
|
||||
|
||||
def test_dry_run_deletes_nothing(self):
|
||||
from admin_panel.models import SMSDedup
|
||||
class CoordinationDoneConfirmTests(TestCase):
|
||||
"""پیشفرض «انجام شده» برای هماهنگی + هشدار و تأیید پیش از ثبت.
|
||||
|
||||
SMSDedup.objects.create(key='visit:10:24663')
|
||||
call_command('cleanup_visit_dedup', '--dry-run', stdout=io.StringIO())
|
||||
self.assertEqual(self._keys(), {'visit:10:24663'})
|
||||
قواعد قفلشده:
|
||||
* فقط «هماهنگی» تیکِ خودکار میگیرد؛ سایر انواع اقدام دستنخوردهاند.
|
||||
* پیش از ثبتِ هماهنگیِ انجامشده، تأیید گرفته میشود.
|
||||
* در حالت ویرایش و برای انواع دیگر، هیچ تأییدی در مسیر نیست.
|
||||
"""
|
||||
|
||||
def test_customer_and_action_rows_untouched(self):
|
||||
from admin_panel.models import SMSDedup
|
||||
def setUp(self):
|
||||
self.user = make_user('confirm_viewer')
|
||||
self.user.user_permissions.add(*Permission.objects.filter(
|
||||
content_type__app_label='admin_panel'))
|
||||
self.client.force_login(self.user)
|
||||
self.customer = make_customer('مشتری تأیید هماهنگی')
|
||||
self.html = self.client.get(
|
||||
reverse('admin_panel:customerAction', kwargs={'c_id': self.customer.pk})
|
||||
).content.decode()
|
||||
|
||||
customer = make_customer('مشتری پاکسازی کلید')
|
||||
action = make_visit(customer, action_dt=at(-30), finish_dt=at(-30))
|
||||
before_action = action_snapshot(action)
|
||||
before_customer = Customer.objects.values().get(pk=customer.pk)
|
||||
SMSDedup.objects.create(key='visit:1:24663')
|
||||
def test_warning_panel_starts_hidden(self):
|
||||
self.assertIn('id="holdDoneWarning"', self.html)
|
||||
block = self.html.split('id="holdDoneWarning"')[0].rsplit('<div', 1)[-1]
|
||||
self.assertIn('is-hidden', block, 'پنل هشدار نباید از ابتدا باز باشد')
|
||||
|
||||
call_command('cleanup_visit_dedup', stdout=io.StringIO())
|
||||
def test_warning_text_is_present(self):
|
||||
self.assertIn('آیا مطمئن هستی که هماهنگی را انجام دادی؟', self.html)
|
||||
self.assertIn('اگر هنوز هماهنگی نکردی، این تیک را بردار.', self.html)
|
||||
|
||||
self.assertEqual(action_snapshot(action), before_action)
|
||||
self.assertEqual(Customer.objects.values().get(pk=customer.pk), before_customer)
|
||||
self.assertEqual(self._keys(), set())
|
||||
def test_confirm_buttons_exist(self):
|
||||
self.assertIn('id="holdConfirmYes"', self.html)
|
||||
self.assertIn('id="holdConfirmNo"', self.html)
|
||||
|
||||
def test_autocheck_only_for_coordination(self):
|
||||
# تیک خودکار باید داخل شاخهٔ hamahangi باشد و برای بقیه برداشته شود.
|
||||
block = self.html.split("$(\"select#actionType\").on('change'", 1)[1]
|
||||
block = block.split("});", 1)[0]
|
||||
self.assertIn("type === 'hamahangi'", block)
|
||||
self.assertIn('holdAutoChecked = true', block)
|
||||
self.assertIn('holdAutoChecked = false', block)
|
||||
|
||||
def test_confirmation_is_gated_before_submit(self):
|
||||
block = self.html.split('$("#ActionBox").on(\'submit\'', 1)[1]
|
||||
block = block.split('});', 1)[0]
|
||||
# سه شرط خروج زودهنگام: ویرایش/تأییدشده، نوع غیر هماهنگی، تیک نخورده.
|
||||
self.assertIn('isEditMode || holdConfirmed', block)
|
||||
self.assertIn("!== 'hamahangi'", block)
|
||||
self.assertIn('prop("checked")', block)
|
||||
self.assertIn('event.preventDefault()', block)
|
||||
|
||||
def test_cancelling_confirmation_unchecks(self):
|
||||
block = self.html.split('$("#holdConfirmNo").click', 1)[1]
|
||||
block = block.split('});', 1)[0]
|
||||
self.assertIn('prop("checked", false)', block)
|
||||
|
||||
def test_edit_mode_skips_confirmation(self):
|
||||
# با کلیک روی ردیف موجود، isEditMode روشن میشود ⇒ مسیر تأیید رد میشود.
|
||||
self.assertIn('isEditMode = true', self.html)
|
||||
self.assertIn('isEditMode = false', self.html)
|
||||
|
||||
def test_other_action_types_keep_old_done_toggle(self):
|
||||
# تیک «انجام شده» همچنان فیلدهای اتمام/نتیجه را نشان میدهد.
|
||||
self.assertIn('function syncDoneFields', self.html)
|
||||
self.assertIn('showWhenDone', self.html)
|
||||
|
||||
def test_every_picker_selector_matches_markup(self):
|
||||
"""هر کلاسی که در JS برای ساخت تقویم استفاده میشود باید در قالب باشد.
|
||||
|
||||
این تست جلوی باگ «تقویم باز نمیشود» را میگیرد: اگر نام کلاس در قالب
|
||||
با سلکتور جاوااسکریپت یکی نباشد، ``pDatepicker`` روی مجموعهٔ خالی
|
||||
اجرا میشود، ``undefined`` برمیگرداند و ``setDate`` با TypeError
|
||||
میشکند — که باعث از کار افتادن بقیهٔ منطق مودال هم میشد.
|
||||
"""
|
||||
selectors = re.findall(r'\$\("\.([\w-]+)"\)\.pDatepicker\(', self.html)
|
||||
self.assertTrue(selectors, 'هیچ پیکری در قالب پیدا نشد')
|
||||
for selector in selectors:
|
||||
self.assertRegex(
|
||||
self.html,
|
||||
rf'class="[^"]*\b{re.escape(selector)}\b',
|
||||
f'تقویم .{selector} در JS هست ولی در قالب هیچ المانی با این کلاس نیست',
|
||||
)
|
||||
|
||||
def test_every_alt_field_class_exists_in_markup(self):
|
||||
"""کلاس ``altField`` هر پیکر باید در قالب وجود داشته باشد."""
|
||||
alt_fields = re.findall(r"altField:\s*'\.([\w-]+)'", self.html)
|
||||
self.assertTrue(alt_fields, 'هیچ altField ای پیدا نشد')
|
||||
for selector in alt_fields:
|
||||
self.assertRegex(
|
||||
self.html,
|
||||
rf'class="[^"]*\b{re.escape(selector)}\b',
|
||||
f'altField .{selector} در قالب وجود ندارد',
|
||||
)
|
||||
|
||||
def test_hold_picker_calls_are_guarded(self):
|
||||
"""فراخوانی setDate پیکر «تاریخ پیگیری» باید فقط از مسیر محافظشده باشد.
|
||||
|
||||
چون ``pDatepicker`` در صورت عدم تطبیق سلکتور ``undefined`` میدهد،
|
||||
فراخوانی مستقیم ``.setDate()`` کل هندلر را میشکند. پس تنها فراخوانی
|
||||
مجاز، همان یکی داخل ``resetHoldPicker`` است و بقیهٔ نقاط باید از آن
|
||||
استفاده کنند.
|
||||
"""
|
||||
self.assertIn('function resetHoldPicker', self.html)
|
||||
calls = re.findall(r'dateTimePickerHoldUntil\.setDate\(\)', self.html)
|
||||
self.assertEqual(len(calls), 1,
|
||||
'همهٔ ریستها باید از resetHoldPicker عبور کنند')
|
||||
|
||||
# آن یک فراخوانی باید داخل تابع محافظ باشد (بعد از بررسی typeof).
|
||||
func = self.html.split('function resetHoldPicker', 1)[1].split('\n }', 1)[0]
|
||||
self.assertIn('typeof dateTimePickerHoldUntil.setDate === \'function\'', func)
|
||||
self.assertIn('dateTimePickerHoldUntil.setDate()', func)
|
||||
|
||||
# نقاط استفاده باید resetHoldPicker را صدا بزنند.
|
||||
self.assertGreaterEqual(self.html.count('resetHoldPicker()'), 3)
|
||||
|
||||
|
||||
class EntezarLayoutTests(TestCase):
|
||||
"""ثابتهای چیدمان صفحهٔ انتظار (جلوگیری از رگرسیون UI).
|
||||
|
||||
این تستها چیزهایی را قفل میکنند که با یک ویرایش ساده میشکنند و در
|
||||
تستهای منطقی دیده نمیشوند: تناسب ستونها، حذف ستون «ردیف»، و همترازی
|
||||
مرز جدول/کارت.
|
||||
"""
|
||||
|
||||
URL = '/panel/entezar/'
|
||||
|
||||
def setUp(self):
|
||||
self.user = make_user('layout_viewer')
|
||||
self.user.user_permissions.add(*Permission.objects.filter(
|
||||
content_type__app_label='admin_panel'))
|
||||
self.client.force_login(self.user)
|
||||
customer = make_customer('مشتری چیدمان')
|
||||
Customer.objects.filter(pk=customer.pk).update(
|
||||
installDate=(timezone.now() - timedelta(days=900)).date(),
|
||||
phone1='09121234567', idCode='C-1',
|
||||
)
|
||||
make_visit(customer, action_dt=at(-400), finish_dt=at(-400))
|
||||
self.html = self.client.get(self.URL, {'view': 'all'}).content.decode()
|
||||
|
||||
def test_column_widths_sum_to_one_hundred(self):
|
||||
widths = [float(w) for w in re.findall(
|
||||
r'<col style="width: (\d+(?:\.\d+)?)%">', self.html)]
|
||||
self.assertEqual(len(widths), 8, 'تعداد ستونها باید ۸ باشد')
|
||||
self.assertAlmostEqual(sum(widths), 100.0, places=1)
|
||||
|
||||
def test_row_number_is_not_a_column(self):
|
||||
# شمارهٔ ردیف به چیپ کنار نام تبدیل شده، نه ستونِ جداگانه.
|
||||
self.assertNotIn('<th>ردیف</th>', self.html)
|
||||
self.assertIn('ez-idx', self.html)
|
||||
|
||||
def test_header_cells_match_body_cells(self):
|
||||
thead = self.html.split('<tbody>')[0]
|
||||
first_row = self.html.split('<tbody>')[1].split('</tr>')[0]
|
||||
self.assertEqual(thead.count('<th>'), first_row.count('<td'))
|
||||
|
||||
def test_no_fixed_min_width_on_columns(self):
|
||||
# min-width روی ستونها باعث اسکرول افقی و بیتناسبی میشد.
|
||||
self.assertEqual(re.findall(r'<th[^>]*min-width', self.html), [])
|
||||
|
||||
def test_card_and_table_breakpoints_agree(self):
|
||||
# اگر مرز کارت (max-width) و جدول (min-width) یکی نباشند، محدودهای
|
||||
# از عرض صفحه هر دو حالت را همزمان میگیرد و چیدمان میشکند.
|
||||
cards = re.findall(r'@media \(max-width: (\d+)px\)', self.html)
|
||||
tables = [w for w in re.findall(r'@media \(min-width: (\d+)px\)', self.html)
|
||||
if w != '1024'] # 1024 مخصوص نوار آمار است
|
||||
self.assertTrue(cards, 'بلوک کارت موبایل پیدا نشد')
|
||||
self.assertIn(str(int(cards[0]) + 1), tables)
|
||||
|
||||
def test_phone_renders_left_to_right(self):
|
||||
self.assertIn('ez-phone', self.html)
|
||||
# شمارهٔ تلفن باید در یک تکه و بدون شکست نمایش داده شود.
|
||||
self.assertIn('ez-nowrap', self.html)
|
||||
|
||||
|
||||
class EntezarPageControlsTests(TestCase):
|
||||
"""کنترلهای صفحهٔ *انتظار*: فیلتر نما، مرتبسازی، جستوجو و کارتهای آمار.
|
||||
|
||||
این تستها رفتار کاربری را قفل میکنند: هر عدد کارت باید با تعداد همان
|
||||
نما بخواند و جستوجو/مرتبسازی نباید ردیفها را از دست بدهد.
|
||||
"""
|
||||
|
||||
URL = '/panel/entezar/'
|
||||
|
||||
def setUp(self):
|
||||
self.user = make_user('entezar_viewer')
|
||||
self.user.user_permissions.add(*Permission.objects.filter(
|
||||
content_type__app_label='admin_panel'))
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def _get(self, **params):
|
||||
return self.client.get(self.URL, params)
|
||||
|
||||
def _overdue_customer(self, name, install_days_ago=900, visit_days_ago=None):
|
||||
customer = make_customer(name)
|
||||
Customer.objects.filter(pk=customer.pk).update(
|
||||
installDate=(timezone.now() - timedelta(days=install_days_ago)).date())
|
||||
if visit_days_ago is not None:
|
||||
make_visit(customer, action_dt=at(-visit_days_ago),
|
||||
finish_dt=at(-visit_days_ago))
|
||||
return Customer.objects.get(pk=customer.pk)
|
||||
|
||||
def test_default_view_is_uncoordinated(self):
|
||||
response = self._get()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context['view'], 'uncoordinated')
|
||||
self.assertEqual(response.context['sort'], 'oldest')
|
||||
|
||||
def test_invalid_view_and_sort_fall_back(self):
|
||||
response = self._get(view='nonsense', sort='bogus')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context['view'], 'uncoordinated')
|
||||
self.assertEqual(response.context['sort'], 'oldest')
|
||||
|
||||
def test_counts_partition_total(self):
|
||||
self._overdue_customer('معوق ساده', visit_days_ago=400)
|
||||
counts = self._get().context['counts']
|
||||
self.assertEqual(counts['total'],
|
||||
counts['uncoordinated'] + counts['coordinated'])
|
||||
self.assertEqual(sum(counts['buckets'].values()), counts['uncoordinated'])
|
||||
|
||||
def test_coordinated_customer_hidden_in_default_view(self):
|
||||
customer = self._overdue_customer('با قرار', visit_days_ago=400)
|
||||
# قرار فقط با هماهنگیِ فعال معتبر است (قاعدهٔ «ستون ≠ مرجع»).
|
||||
make_visit(customer, action_dt=at(-1), action_type='hamahangi', status='waite')
|
||||
set_visit_hold(customer, at(+10))
|
||||
|
||||
default_ids = [obj.id for obj in self._get().context['customers']]
|
||||
self.assertNotIn(customer.id, default_ids)
|
||||
|
||||
all_ids = [obj.id for obj in self._get(view='all').context['customers']]
|
||||
self.assertIn(customer.id, all_ids)
|
||||
|
||||
manual_ids = [obj.id for obj in self._get(view='manual').context['customers']]
|
||||
self.assertIn(customer.id, manual_ids)
|
||||
|
||||
def test_cancelled_coordination_returns_customer_to_list(self):
|
||||
customer = self._overdue_customer('هماهنگی لغو شده', visit_days_ago=400)
|
||||
coordination = make_visit(customer, action_dt=at(-1),
|
||||
action_type='hamahangi', status='waite')
|
||||
set_visit_hold(customer, at(+10))
|
||||
self.assertNotIn(customer.id,
|
||||
[obj.id for obj in self._get().context['customers']])
|
||||
|
||||
coordination.status = 'cancell'
|
||||
coordination.save(update_fields=['status'])
|
||||
self.assertIn(customer.id,
|
||||
[obj.id for obj in self._get().context['customers']])
|
||||
|
||||
def test_search_by_name_and_phone(self):
|
||||
customer = self._overdue_customer('علی رضایی', visit_days_ago=400)
|
||||
PhoneNumber.objects.create(customer=customer, title='تلفن',
|
||||
phone_number='09121234567')
|
||||
Customer.objects.filter(pk=customer.pk).update(phone1='09121234567')
|
||||
|
||||
for field, value in (('name', 'رضایی'), ('phone', '09121234567')):
|
||||
response = self._get(searchVal=value, searchOn=field)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(customer.id,
|
||||
[obj.id for obj in response.context['customers']])
|
||||
|
||||
def test_sort_overdue_puts_biggest_delay_first(self):
|
||||
oldest = self._overdue_customer('خیلی معوق', visit_days_ago=900)
|
||||
newer = self._overdue_customer('کمتر معوق', visit_days_ago=300)
|
||||
|
||||
ids = [obj.id for obj in self._get(view='all', sort='overdue').context['customers']]
|
||||
self.assertLess(ids.index(oldest.id), ids.index(newer.id))
|
||||
|
||||
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))
|
||||
Reference in new issue
Block a user