improve bazdid

This commit is contained in:
javad committed 2026-10-21 10:50:42 +03:30
1 parent 2aee85593f
commit 534a84f977
12 files changed
+2305 -339

No files matched your search

+5 -3
View File
@@ -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='قرار بعدی (تعویق بازدید)'),
),
]
+19
View File
@@ -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,
+334 -156
View File
@@ -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 %}
+16 -1
View File
@@ -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)
+39 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+468
View File
@@ -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. عدد «بدون قرار» در داشبورد همان لیست کاریِ فوری است.
+2
View File
@@ -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
View File
@@ -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))