improve bazdid
This commit is contained in:
1 parent
2aee85593f
commit
534a84f977
12 files changed
+2305
-339
No files matched your search
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.2.25 on 2026-10-20 18:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('admin_panel', '0078_action_visit_covering_index'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customer',
|
||||
name='visit_hold_set_at',
|
||||
field=models.DateTimeField(blank=True, help_text='برای حسابرسی: آخرین باری که مقدار «قرار بعدی» نوشته شد.', null=True, verbose_name='زمان ثبت قرار'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='customer',
|
||||
name='visit_hold_until',
|
||||
field=models.DateTimeField(blank=True, help_text='تا پایان این روز پیگیری لازم نیست؛ از فرم «هماهنگی» تنظیم می\u200cشود.', null=True, verbose_name='قرار بعدی (تعویق بازدید)'),
|
||||
),
|
||||
]
|
||||
@@ -39,6 +39,25 @@ class Customer(models.Model):
|
||||
help_text="شمارههای نرمالشده (+98…) جداشده با کاما؛ خالی = شمارهٔ اول مشتری.",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# «قرار بعدی» — تعویقِ دستیِ بازدید (تصمیم محصولی)
|
||||
# ------------------------------------------------------------------
|
||||
# این ستون **اعتبار مستقل ندارد**: فقط وقتی اثر دارد که یک اقدام «هماهنگی»
|
||||
# فعال (``waite``/``finish``) با تاریخ آینده برای همان مشتری وجود داشته باشد.
|
||||
# منبع حقیقت همچنان جدول ``Action`` است و این مقدار یک میانبر سریع برای
|
||||
# نمایش/فیلتر است. اگر هماهنگی حذف/لغو شود، این مقدار خودبهخود بیاثر میشود
|
||||
# (نگاه کنید به ``visit_policy.manual_holds_map``).
|
||||
visit_hold_until = models.DateTimeField(
|
||||
null=True, blank=True,
|
||||
verbose_name="قرار بعدی (تعویق بازدید)",
|
||||
help_text="تا پایان این روز پیگیری لازم نیست؛ از فرم «هماهنگی» تنظیم میشود.",
|
||||
)
|
||||
visit_hold_set_at = models.DateTimeField(
|
||||
null=True, blank=True,
|
||||
verbose_name="زمان ثبت قرار",
|
||||
help_text="برای حسابرسی: آخرین باری که مقدار «قرار بعدی» نوشته شد.",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name_family
|
||||
|
||||
|
||||
@@ -161,27 +161,160 @@
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ===== مودال «ایجاد/ویرایش وظیفه» — mobile first =====
|
||||
همهٔ کلاسها با پیشوند ax- هستند تا با سایر بخشها تداخل نکنند. */
|
||||
#ActionBox .ax-card {
|
||||
width: calc(100% - 1rem); max-width: 44rem;
|
||||
max-height: calc(100vh - 1rem); display: flex; flex-direction: column;
|
||||
border-radius: .75rem; overflow: hidden;
|
||||
}
|
||||
#ActionBox .ax-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: .5rem; padding: .75rem 1rem; flex: 0 0 auto;
|
||||
background: #f8fafc; border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
#ActionBox .ax-title { font-size: .95rem; font-weight: 800; color: #0f172a; margin: 0; }
|
||||
#ActionBox .ax-sub { font-size: .75rem; color: #64748b; margin: .1rem 0 0; }
|
||||
#ActionBox .ax-body {
|
||||
padding: .75rem 1rem; overflow-y: auto; -webkit-overflow-scrolling: touch;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
#ActionBox .ax-foot {
|
||||
display: flex; gap: .5rem; justify-content: flex-start;
|
||||
padding: .6rem 1rem; flex: 0 0 auto;
|
||||
background: #f8fafc; border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
#ActionBox .ax-foot .button { margin: 0 !important; min-height: 2.6rem; flex: 1 1 0; }
|
||||
@media (min-width: 768px) {
|
||||
#ActionBox .ax-foot { justify-content: flex-start; }
|
||||
#ActionBox .ax-foot .button { flex: 0 0 auto; min-width: 7rem; }
|
||||
}
|
||||
|
||||
/* بخشها */
|
||||
#ActionBox .ax-section { padding: .6rem 0; border-bottom: 1px dashed #eef2f6; }
|
||||
#ActionBox .ax-section:last-of-type { border-bottom: 0; }
|
||||
#ActionBox .ax-legend {
|
||||
display: flex; align-items: center; gap: .4rem;
|
||||
font-size: .78rem; font-weight: 800; color: #0e7490; margin: 0 0 .5rem;
|
||||
}
|
||||
#ActionBox .ax-step {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 1.35rem; height: 1.35rem; border-radius: 999px;
|
||||
background: #33b5d3; color: #fff; font-size: .7rem; font-weight: 800;
|
||||
}
|
||||
|
||||
/* شبکهٔ فیلدها: موبایل تکستونه، دسکتاپ دوستونه */
|
||||
#ActionBox .ax-grid { display: grid; grid-template-columns: 1fr; gap: .6rem; }
|
||||
@media (min-width: 768px) {
|
||||
#ActionBox .ax-grid { grid-template-columns: 1fr 1fr; }
|
||||
#ActionBox .ax-span-2 { grid-column: 1 / -1; }
|
||||
}
|
||||
#ActionBox .ax-label {
|
||||
font-size: .74rem !important; font-weight: 700; color: #475569; margin-bottom: .25rem;
|
||||
}
|
||||
#ActionBox .ax-help {
|
||||
font-size: .7rem; color: #94a3b8; margin-top: .2rem; line-height: 1.5;
|
||||
}
|
||||
/* هدف لمسی بزرگتر روی موبایل */
|
||||
#ActionBox .input, #ActionBox .select select, #ActionBox .textarea {
|
||||
font-size: .85rem;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
#ActionBox .input, #ActionBox .select select { min-height: 2.6rem; }
|
||||
#ActionBox .textarea { min-height: 4.5rem; }
|
||||
}
|
||||
#ActionBox .select, #ActionBox .select select { width: 100%; }
|
||||
|
||||
/* ردیف تیک «انجام شده» */
|
||||
#ActionBox .ax-check {
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
padding: .55rem .7rem; border: 1px solid #e5e7eb; border-radius: .5rem;
|
||||
background: #fff; cursor: pointer; margin: 0;
|
||||
}
|
||||
#ActionBox .ax-check input { width: 1.05rem; height: 1.05rem; }
|
||||
|
||||
/* هشدار «آیا هماهنگی را انجام دادی؟» — فقط برای هماهنگیِ تیکخورده */
|
||||
#ActionBox .ax-warn {
|
||||
margin-top: .5rem; padding: .6rem .7rem;
|
||||
border: 1px solid #fcd34d; border-radius: .5rem; background: #fffbeb;
|
||||
color: #92400e; font-size: .78rem; line-height: 1.75;
|
||||
}
|
||||
#ActionBox .ax-warn .ax-warn-q { font-weight: 800; color: #78350f; margin: 0 0 .15rem; }
|
||||
#ActionBox .ax-warn .ax-warn-hint { margin: 0; }
|
||||
#ActionBox .ax-warn .ax-warn-actions {
|
||||
display: none; gap: .4rem; margin-top: .55rem; flex-wrap: wrap;
|
||||
}
|
||||
#ActionBox .ax-warn .ax-warn-actions .button { margin: 0 !important; flex: 1 1 auto; }
|
||||
@media (min-width: 768px) {
|
||||
#ActionBox .ax-warn .ax-warn-actions .button { flex: 0 0 auto; }
|
||||
}
|
||||
/* وقتی ارسال متوقف شده تا کاربر تأیید کند */
|
||||
#ActionBox .ax-warn.is-confirming {
|
||||
border-color: #f59e0b; box-shadow: 0 0 0 3px #fde68a66; background: #fef3c7;
|
||||
}
|
||||
#ActionBox .ax-warn.is-confirming .ax-warn-actions { display: flex; }
|
||||
#ActionBox .ax-warn:focus { outline: 2px solid #f59e0b; outline-offset: 2px; }
|
||||
|
||||
#ActionBox .ax-help-toggle {
|
||||
display: flex; align-items: center; gap: .45rem; width: 100%;
|
||||
padding: .55rem .7rem; border: 1px solid #bae6fd; border-radius: .5rem;
|
||||
background: #f0f9ff; color: #0369a1; font-size: .78rem; font-weight: 700;
|
||||
cursor: pointer; text-align: right; line-height: 1.4;
|
||||
}
|
||||
#ActionBox .ax-help-toggle:hover { background: #e0f2fe; }
|
||||
#ActionBox .ax-help-toggle .ax-ico {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 1.25rem; height: 1.25rem; border-radius: 999px; flex: 0 0 auto;
|
||||
background: #0284c7; color: #fff; font-size: .72rem; font-weight: 800;
|
||||
}
|
||||
#ActionBox .ax-help-toggle .ax-chev {
|
||||
margin-inline-start: auto; transition: transform .15s ease; font-size: .8rem;
|
||||
}
|
||||
#ActionBox .ax-help-toggle[aria-expanded="true"] .ax-chev { transform: rotate(180deg); }
|
||||
#ActionBox .ax-help-panel {
|
||||
margin-top: .4rem; padding: .7rem .8rem; border: 1px solid #e0f2fe;
|
||||
border-radius: .5rem; background: #fbfeff; font-size: .76rem; color: #334155;
|
||||
line-height: 1.85;
|
||||
}
|
||||
#ActionBox .ax-help-panel h4 {
|
||||
font-size: .78rem; font-weight: 800; color: #0f172a; margin: .6rem 0 .25rem;
|
||||
}
|
||||
#ActionBox .ax-help-panel h4:first-child { margin-top: 0; }
|
||||
#ActionBox .ax-help-panel ul { margin: 0; padding-inline-start: 1.1rem; }
|
||||
#ActionBox .ax-help-panel li { margin-bottom: .2rem; }
|
||||
#ActionBox .ax-help-panel .ax-eg {
|
||||
display: block; margin-top: .35rem; padding: .5rem .6rem;
|
||||
background: #fff; border: 1px dashed #bae6fd; border-radius: .4rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% if customer %}
|
||||
<form id="ActionBox" class="modal" method="post" action="{% url 'admin_panel:customerNewAction' customer.id %}">
|
||||
{% else %}
|
||||
<form id="ActionBox" class="modal" method="post" action="">
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% csrf_token %}
|
||||
<div class="modal-background closeActionBox"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title is-hidden">ایجاد وظیفه</p>
|
||||
<button type="button" class="delete closeActionBox" aria-label="close"></button>
|
||||
<div class="modal-card ax-card">
|
||||
<header class="modal-card-head ax-head">
|
||||
<div>
|
||||
<p class="ax-title">ایجاد وظیفه</p>
|
||||
{% if customer %}<p class="ax-sub">{{ customer.name_family }}</p>{% endif %}
|
||||
</div>
|
||||
<button type="button" class="delete closeActionBox" aria-label="بستن"></button>
|
||||
</header>
|
||||
<section class="modal-card-body">
|
||||
|
||||
<div class="columns is-mobile is-multiline">
|
||||
<div class="field column is-6-desktop is-12-mobile m-0">
|
||||
<label class="label">نوع اقدام</label>
|
||||
<div class="control">
|
||||
<div class="select" style="width: 100%;">
|
||||
<select id="actionType" required name="actionType" style="width: 100%;">
|
||||
<section class="modal-card-body ax-body">
|
||||
|
||||
{# ---------- گام ۱: نوع اقدام و مسئول ---------- #}
|
||||
<div class="ax-section">
|
||||
<p class="ax-legend"><span class="ax-step">۱</span> نوع اقدام و مسئول</p>
|
||||
<div class="ax-grid">
|
||||
<div class="ax-field">
|
||||
<label class="label ax-label" for="actionType">نوع اقدام</label>
|
||||
<div class="select">
|
||||
<select id="actionType" required name="actionType">
|
||||
<option value="">انتخاب کنید</option>
|
||||
<option value="bazdid">بازدید</option>
|
||||
<option value="nasb">نصب</option>
|
||||
@@ -192,29 +325,11 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-6-desktop is-12-mobile m-0">
|
||||
<label class="label">تاریخ و ساعتی که کار باید انجام شود</label>
|
||||
<div class="control">
|
||||
<input type="text" class="input dateTimePickerAnjam">
|
||||
<input id="dateTimeAnjam" name="dateTimeAnjam" required type="text" class="input is-hidden dateTimePickerAnjam-alt">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-12-desktop is-12-mobile m-0">
|
||||
<div class="control">
|
||||
<label class="label mb-0">توضیحات برای یادآوری حین انجام کار:</label>
|
||||
<p class="is-size-7">راجب کاری که در آینده میخواهید انجام دهید توضیحاتی را بنویسید</p>
|
||||
<textarea id="desc" required name="desc" class="textarea" placeholder="مثلا: بامشتری تماس بگیرم برای هماهنگی تعویض فیلتر دستگاه"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-6-desktop is-12-mobile m-0">
|
||||
<label class="label">توسط</label>
|
||||
<div class="control">
|
||||
<div class="select" style="width: 100%;">
|
||||
<select id="userID" required name="userID" style="width: 100%;">
|
||||
|
||||
<div class="ax-field">
|
||||
<label class="label ax-label" for="userID">توسط</label>
|
||||
<div class="select">
|
||||
<select id="userID" required name="userID">
|
||||
<option value="">انتخاب کنید</option>
|
||||
{% if perms.admin_panel.ViewStore %}
|
||||
{% for auser in allUsers %}
|
||||
@@ -228,47 +343,153 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-12-desktop is-12-mobile m-0">
|
||||
<div class="control">
|
||||
<label class="checkbox">
|
||||
<input name="is_done" id="checkIsDone" type="checkbox">
|
||||
این کار انجام شده
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-12-desktop is-12-mobile m-0 showWhenDone is-hidden">
|
||||
<label class="label">تاریخ و ساعت اتمام کار</label>
|
||||
<div class="control">
|
||||
<input id="FinishDateTime" type="text" class="input dateTimePickerFinish">
|
||||
<input name="FinishDateTime" type="text" class="input dateTimePickerFinish-alt is-hidden">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-12-desktop is-12-mobile m-0 showWhenDone is-hidden">
|
||||
<div class="control">
|
||||
<label class="label mb-0">نتیجه</label>
|
||||
<p class="is-size-7">نتیجه کار یا پیگیری که انجام داده اید را بنویسید</p>
|
||||
<textarea name="result" id="actionResult" class="textarea" placeholder="نتیجه فعالیت انجام شده را بنویسید"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# ---------- گام ۲: زمانبندی ---------- #}
|
||||
<div class="ax-section">
|
||||
<p class="ax-legend"><span class="ax-step">۲</span> زمانبندی</p>
|
||||
<div class="ax-grid">
|
||||
<div class="ax-field">
|
||||
<label class="label ax-label">تاریخ و ساعتی که کار باید انجام شود</label>
|
||||
<input type="text" class="input dateTimePickerAnjam" autocomplete="off">
|
||||
<input id="dateTimeAnjam" name="dateTimeAnjam" required type="text" class="input is-hidden dateTimePickerAnjam-alt">
|
||||
<p class="ax-help">تاریخ انجام این وظیفه (ثبت در «لیست وظایف» همان روز).</p>
|
||||
</div>
|
||||
|
||||
{# فیلد «تا تاریخ پیگیری نشود» فقط برای «هماهنگی» #}
|
||||
<div class="ax-field showWhenCoordination is-hidden">
|
||||
<label class="label ax-label">تا تاریخ پیگیری نشود</label>
|
||||
<input type="text" class="input dateTimePickerHoldUntil" autocomplete="off">
|
||||
<input name="visitHoldUntil" id="visitHoldUntil" type="text" class="input is-hidden dateTimePickerHoldUntil-alt">
|
||||
<p class="ax-help">تا پایان این روز، مشتری در «انتظار بازدید» دیده نمیشود. خالی = ۱۴ روز.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# راهنمای بازشونده — فقط برای هماهنگی #}
|
||||
<div class="ax-help-wrap showWhenCoordination is-hidden" style="margin-top: .6rem;">
|
||||
<button type="button" class="ax-help-toggle" id="holdHelpToggle"
|
||||
aria-expanded="false" aria-controls="holdHelpPanel">
|
||||
<span class="ax-ico" aria-hidden="true">!</span>
|
||||
<span>راهنمای «هماهنگی» و «تا تاریخ پیگیری نشود»</span>
|
||||
<span class="ax-chev" aria-hidden="true">▾</span>
|
||||
</button>
|
||||
<div class="ax-help-panel is-hidden" id="holdHelpPanel">
|
||||
<h4>این تاریخ چه کاری میکند؟</h4>
|
||||
<p>میگوید «تا این تاریخ با این مشتری تماس نگیرید». در این بازه، مشتری از فهرست
|
||||
<span class="has-text-weight-bold">انتظار بازدید</span> پنهان میشود و روز بعد از آن
|
||||
تاریخ <span class="has-text-weight-bold">خودکار</span> برمیگردد. تاریخ تا
|
||||
<span class="has-text-weight-bold">پایان همان روز</span> معتبر است.</p>
|
||||
|
||||
<h4>مثال</h4>
|
||||
<div class="ax-eg">
|
||||
امروز ۱ آذر است. مشتری میگوید «سه هفتهٔ دیگر زنگ بزنید».<br>
|
||||
نوع = <b>هماهنگی</b> ← تیک «این کار انجام شده» ← «تا تاریخ پیگیری نشود» = <b>۲۲ آذر</b>.<br>
|
||||
نتیجه: از ۱ تا ۲۲ آذر مشتری در فهرست نیست؛ از ۲۳ آذر خودکار برمیگردد.
|
||||
</div>
|
||||
|
||||
<h4>نکات مهم</h4>
|
||||
<ul>
|
||||
<li><b>خالی + تیک «انجام شد»</b> ← سیستم خودش ۱۴ روز میگذارد.</li>
|
||||
<li><b>خالی + بدون تیک</b> ← هیچ تعویقی ثبت نمیشود؛ مشتری در فهرست میماند.</li>
|
||||
<li><b>فقط تمدید میکند، کوتاه نمیکند:</b> اگر قرار ۲۲ آذر باشد و همکار دیگری ۳۰ آذر بگذارد،
|
||||
قرار به ۳۰ آذر میرود؛ اما ۱۵ آذر نادیده گرفته میشود. برای کوتاهکردن،
|
||||
هماهنگی را حذف و دوباره ثبت کنید.</li>
|
||||
<li><b>حذف یا لغو هماهنگی</b> ← قرار بیاثر میشود و مشتری بلافاصله برمیگردد.</li>
|
||||
<li><b>سقف ۶ ماه</b> است؛ بیشتر از آن با پیام خطا رد میشود.</li>
|
||||
<li>این فیلد <b>تاریخ بازدید پرسنل نیست</b> و <b>بازدید حساب نمیشود</b>؛
|
||||
تاریخ آخرین بازدید و دورهٔ ۹ ماههٔ پیامک دست نمیخورد.
|
||||
برای اعزام پرسنل از اقدام نوع <b>بازدید</b> استفاده کنید.</li>
|
||||
<li>در مدت تعویق، <b>پیامک یادآوری ۹ ماههٔ بازدید</b> ارسال نمیشود.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---------- گام ۳: توضیحات ---------- #}
|
||||
<div class="ax-section">
|
||||
<p class="ax-legend"><span class="ax-step">۳</span> توضیحات و وضعیت</p>
|
||||
<div class="ax-grid">
|
||||
<div class="ax-field ax-span-2">
|
||||
<label class="label ax-label" for="desc">توضیحات برای یادآوری حین انجام کار</label>
|
||||
<textarea id="desc" required name="desc" class="textarea"
|
||||
placeholder="مثلا: با مشتری تماس بگیرم برای هماهنگی تعویض فیلتر دستگاه"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="ax-field ax-span-2">
|
||||
<label class="ax-check">
|
||||
<input name="is_done" id="checkIsDone" type="checkbox">
|
||||
<span>این کار انجام شده است</span>
|
||||
</label>
|
||||
|
||||
{# هشدار/تأیید: فقط برای «هماهنگی» با تیکِ «انجام شده» #}
|
||||
<div class="ax-warn is-hidden" id="holdDoneWarning" tabindex="-1"
|
||||
role="alert" aria-live="polite">
|
||||
<p class="ax-warn-q">آیا مطمئن هستی که هماهنگی را انجام دادی؟</p>
|
||||
<p class="ax-warn-hint">اگر هنوز هماهنگی نکردی، این تیک را بردار.</p>
|
||||
<div class="ax-warn-actions">
|
||||
<button type="button" id="holdConfirmYes" class="button is-warning is-small">
|
||||
بله، هماهنگی انجام شد
|
||||
</button>
|
||||
<button type="button" id="holdConfirmNo" class="button is-light is-small">
|
||||
نه، تیک را بردار
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ax-field showWhenDone is-hidden">
|
||||
<label class="label ax-label" for="FinishDateTime">تاریخ و ساعت اتمام کار</label>
|
||||
<input id="FinishDateTime" type="text" class="input dateTimePickerFinish" autocomplete="off">
|
||||
<input name="FinishDateTime" type="text" class="input dateTimePickerFinish-alt is-hidden">
|
||||
<p class="ax-help">خالی بماند، زمان ثبت در نظر گرفته میشود.</p>
|
||||
</div>
|
||||
|
||||
<div class="ax-field showWhenDone is-hidden">
|
||||
<label class="label ax-label" for="actionResult">نتیجه</label>
|
||||
<textarea name="result" id="actionResult" class="textarea"
|
||||
placeholder="نتیجهٔ کار یا پیگیری انجامشده را بنویسید"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<footer class="modal-card-foot">
|
||||
<button type="button" class="button closeActionBox">لغو</button>
|
||||
<footer class="modal-card-foot ax-foot">
|
||||
<button type="button" class="button closeActionBox">انصراف</button>
|
||||
<input id="sabtBtn05" type="submit" class="button is-success" value="ثبت"/>
|
||||
</footer>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// وضعیت مودال:
|
||||
// isEditMode ⇒ در حال ویرایش یک اقدام موجود (نه ایجاد)
|
||||
// holdAutoChecked ⇒ تیک «انجام شده» را خودِ سیستم برای هماهنگی زده است
|
||||
// holdConfirmed ⇒ کاربر تأیید کرده که هماهنگی انجام شده است
|
||||
var isEditMode = false;
|
||||
var holdAutoChecked = false;
|
||||
var holdConfirmed = false;
|
||||
|
||||
// پیکر «تا تاریخ پیگیری نشود» را ریست میکند.
|
||||
// نکته: pDatepicker یک Model برمیگرداند؛ اگر سلکتور هیچ المانی پیدا نکند
|
||||
// مقدار undefined میشود. بدون این محافظ، یک ناهمخوانی نام کلاس باعث
|
||||
// TypeError میشد و بقیهٔ منطقِ هندلر (پنهانکردن فیلدها، برداشتن تیک خودکار)
|
||||
// اجرا نمیشد.
|
||||
function resetHoldPicker() {
|
||||
if (dateTimePickerHoldUntil && typeof dateTimePickerHoldUntil.setDate === 'function') {
|
||||
dateTimePickerHoldUntil.setDate();
|
||||
}
|
||||
$("#visitHoldUntil").val('');
|
||||
}
|
||||
|
||||
$(".closeActionBox").click(function(){
|
||||
$("#ActionBox").removeClass("is-active");
|
||||
});
|
||||
|
||||
$("#showActionBox").click(function(){
|
||||
isEditMode = false;
|
||||
holdAutoChecked = false;
|
||||
holdConfirmed = false;
|
||||
$("#holdDoneWarning").addClass("is-hidden").removeClass("is-confirming");
|
||||
$("#ActionBox").addClass("is-active");
|
||||
{% if customer %}
|
||||
var NewActionUrl = "{% url 'admin_panel:customerNewAction' customer.id %}";
|
||||
@@ -282,18 +503,77 @@
|
||||
$("#actionResult").val('');
|
||||
dateTimePickerAnjam.setDate();
|
||||
dateTimePickerFinish.setDate();
|
||||
resetHoldPicker();
|
||||
$(".showWhenCoordination").addClass("is-hidden");
|
||||
closeHoldHelp();
|
||||
$("#sabtBtn05").val("ثبت");
|
||||
});
|
||||
|
||||
$("#checkIsDone").change(function() {
|
||||
if(this.checked) {
|
||||
$(".showWhenDone").removeClass("is-hidden");
|
||||
} else {
|
||||
$(".showWhenDone").addClass("is-hidden");
|
||||
// نمایش/پنهانکردن فیلدهای «انجام شده».
|
||||
function syncDoneFields() {
|
||||
$(".showWhenDone").toggleClass("is-hidden", !$("#checkIsDone").prop("checked"));
|
||||
}
|
||||
|
||||
// هشدار «آیا هماهنگی را انجام دادی؟» فقط برای هماهنگیِ تیکخورده نمایش داده میشود.
|
||||
function syncHoldWarning() {
|
||||
var active = ($("#actionType").val() === 'hamahangi') && $("#checkIsDone").prop("checked");
|
||||
$("#holdDoneWarning").toggleClass("is-hidden", !active);
|
||||
if (!active) {
|
||||
$("#holdDoneWarning").removeClass("is-confirming");
|
||||
holdConfirmed = false;
|
||||
}
|
||||
}
|
||||
|
||||
$("#checkIsDone").change(function() {
|
||||
// تغییر دستی کاربر ⇒ دیگر «تیک خودکار» نیست.
|
||||
holdAutoChecked = false;
|
||||
holdConfirmed = false;
|
||||
syncDoneFields();
|
||||
syncHoldWarning();
|
||||
});
|
||||
|
||||
// تأیید یا ردِ هشدار در لحظهٔ ثبت
|
||||
function showHoldConfirm() {
|
||||
var box = $("#holdDoneWarning");
|
||||
box.removeClass("is-hidden").addClass("is-confirming");
|
||||
if (box[0].scrollIntoView) {
|
||||
box[0].scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
box.trigger('focus');
|
||||
}
|
||||
|
||||
$("#holdConfirmYes").click(function() {
|
||||
holdConfirmed = true;
|
||||
// submit مستقیم روی المان فرم؛ اعتبارسنجی HTML5 در تلاش اول انجام شده است.
|
||||
$("#ActionBox")[0].submit();
|
||||
});
|
||||
|
||||
$("#holdConfirmNo").click(function() {
|
||||
holdConfirmed = false;
|
||||
holdAutoChecked = false;
|
||||
$("#checkIsDone").prop("checked", false);
|
||||
$("#holdDoneWarning").addClass("is-hidden").removeClass("is-confirming");
|
||||
syncDoneFields();
|
||||
$("#checkIsDone").trigger('focus');
|
||||
});
|
||||
|
||||
// گرفتن تأیید پیش از ثبتِ هماهنگیِ «انجامشده» (فقط در حالت ایجاد، نه ویرایش).
|
||||
$("#ActionBox").on('submit', function(event) {
|
||||
if (isEditMode || holdConfirmed) {
|
||||
return; // ویرایش، یا تأیید قبلی ⇒ ارسال آزاد
|
||||
}
|
||||
if ($("#actionType").val() !== 'hamahangi') {
|
||||
return; // سایر انواع اقدام دستنخورده
|
||||
}
|
||||
if (!$("#checkIsDone").prop("checked")) {
|
||||
return; // هماهنگیِ انجامنشده ⇒ بدون تعویق
|
||||
}
|
||||
event.preventDefault();
|
||||
showHoldConfirm();
|
||||
});
|
||||
|
||||
$(".actionBoxOpenB").click(function(){
|
||||
isEditMode = true;
|
||||
$("#sabtBtn05").val("ویرایش");
|
||||
var actionEditUrl = "{% url 'admin_panel:customerEditAction' 1111 2222 %}";
|
||||
var action_id = $(this).parent().attr("action_id");
|
||||
@@ -316,6 +596,12 @@
|
||||
}
|
||||
$("#ActionBox").addClass("is-active");
|
||||
$("#actionType").val(ActionParseData[0]['fields']['actionType']);
|
||||
// در ویرایش، تیکِ خودکار و تأیید نباید درگیر شوند.
|
||||
holdAutoChecked = false;
|
||||
holdConfirmed = false;
|
||||
// فیلد «تا تاریخ پیگیری نشود» با نوع اقدام همراستا شود (در حالت ویرایش هم).
|
||||
closeHoldHelp();
|
||||
toggleCoordinationField(ActionParseData[0]['fields']['actionType']);
|
||||
$("#desc").val(ActionParseData[0]['fields']['desc']);
|
||||
$("#userID").val(ActionParseData[0]['fields']['forUser']);
|
||||
if(ActionParseData[0]['fields']['status'] == 'finish'){
|
||||
@@ -326,6 +612,8 @@
|
||||
$(".showWhenDone").addClass("is-hidden");
|
||||
}
|
||||
$("#actionResult").val(ActionParseData[0]['fields']['result']);
|
||||
// هشدار «هماهنگی انجام شد» در ویرایش هم صرفاً نمایش داده میشود (بدون تأیید).
|
||||
syncHoldWarning();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -347,6 +635,74 @@
|
||||
},
|
||||
});
|
||||
|
||||
var dateTimePickerHoldUntil = $(".dateTimePickerHoldUntil").pDatepicker({
|
||||
altField: '.dateTimePickerHoldUntil-alt',
|
||||
initialValue: false,
|
||||
format: 'YYYY/MM/DD',
|
||||
calendar:{
|
||||
persian: {
|
||||
locale: 'fa'
|
||||
}
|
||||
},
|
||||
timePicker: {
|
||||
enabled: false,
|
||||
second:{
|
||||
enabled: false,
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// اگر پیکر ساخته نشد (مثلاً نام کلاس در قالب عوض شده باشد) هشدار بده؛
|
||||
// این کار جلوی خطاهای خاموش مثل «تقویم باز نمیشود» را میگیرد.
|
||||
if (!dateTimePickerHoldUntil) {
|
||||
console.warn('[ActionBox] pDatepicker برای «تا تاریخ پیگیری نشود» ساخته نشد؛ ' +
|
||||
'کلاس .dateTimePickerHoldUntil را در قالب بررسی کنید.');
|
||||
}
|
||||
|
||||
// فیلد «تا تاریخ پیگیری نشود» فقط برای اقدام «هماهنگی» نمایش داده میشود.
|
||||
function toggleCoordinationField(actionType) {
|
||||
var isCoordination = (actionType === 'hamahangi');
|
||||
$(".showWhenCoordination").toggleClass("is-hidden", !isCoordination);
|
||||
if (!isCoordination) {
|
||||
resetHoldPicker();
|
||||
closeHoldHelp(); // راهنما هم بسته شود تا در اقدام بعدی باز نماند
|
||||
}
|
||||
}
|
||||
|
||||
// ---- راهنمای بازشوندهٔ «هماهنگی» ----
|
||||
function closeHoldHelp() {
|
||||
$("#holdHelpPanel").addClass("is-hidden");
|
||||
$("#holdHelpToggle").attr("aria-expanded", "false");
|
||||
}
|
||||
|
||||
$("#holdHelpToggle").click(function(){
|
||||
var expanded = $(this).attr("aria-expanded") === "true";
|
||||
$("#holdHelpPanel").toggleClass("is-hidden", expanded);
|
||||
$(this).attr("aria-expanded", expanded ? "false" : "true");
|
||||
});
|
||||
|
||||
$("select#actionType").on('change', function(){
|
||||
var type = $(this).val();
|
||||
toggleCoordinationField(type);
|
||||
|
||||
if (type === 'hamahangi') {
|
||||
// پیشفرض: «هماهنگی» یعنی همین حالا تماس گرفته شد ⇒ تیک «انجام شده».
|
||||
// هشدار و تأیید، جلوی ثبتِ ناخواسته را میگیرد.
|
||||
if (!$("#checkIsDone").prop("checked")) {
|
||||
$("#checkIsDone").prop("checked", true);
|
||||
holdAutoChecked = true;
|
||||
}
|
||||
} else if (holdAutoChecked) {
|
||||
// تیک را خودمان زده بودیم ⇒ با تغییر نوع برمیداریم (بهویژه برای «بازدید»).
|
||||
$("#checkIsDone").prop("checked", false);
|
||||
holdAutoChecked = false;
|
||||
}
|
||||
|
||||
holdConfirmed = false;
|
||||
syncDoneFields();
|
||||
syncHoldWarning();
|
||||
});
|
||||
|
||||
var dateTimePickerAnjam = $(".dateTimePickerAnjam").pDatepicker({
|
||||
altField: '.dateTimePickerAnjam-alt',
|
||||
initialValue: true,
|
||||
|
||||
@@ -2,109 +2,379 @@
|
||||
{% load custom_tags %}
|
||||
{% block bodyPage %}
|
||||
|
||||
<div class="">
|
||||
<div class="columns m-0">
|
||||
<style>
|
||||
/* ===================================================================
|
||||
صفحهٔ انتظار بازدید — mobile first
|
||||
مقیاس فاصله: ۰٫۲۵rem (۴px) — همهٔ فاصلهها مضرب همین واحد است.
|
||||
=================================================================== */
|
||||
.ez-wrap { padding: .5rem; }
|
||||
@media (min-width: 769px) { .ez-wrap { padding: 1rem 1.25rem; } }
|
||||
|
||||
<div class="column is-12">
|
||||
<div class="buttons">
|
||||
<a href="#" id="openSearchBox" class="button is-info is-outlined is-rounded mr-0">جستجو</a>
|
||||
/* ---------- نوار آمار ---------- */
|
||||
.ez-stats {
|
||||
display: grid; grid-auto-flow: column; grid-auto-columns: minmax(8.5rem, 1fr);
|
||||
gap: .5rem; overflow-x: auto; padding-bottom: .25rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.ez-stats { grid-auto-flow: row; grid-template-columns: repeat(5, 1fr); overflow: visible; }
|
||||
}
|
||||
.ez-stat {
|
||||
display: block; border: 1px solid #e2e8f0; border-radius: .625rem;
|
||||
background: #fff; padding: .55rem .7rem; text-decoration: none; color: #334155;
|
||||
transition: border-color .15s ease, background-color .15s ease;
|
||||
}
|
||||
.ez-stat:hover { border-color: #94a3b8; }
|
||||
.ez-stat .ez-num {
|
||||
display: block; font-size: 1.2rem; font-weight: 800; line-height: 1.1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ez-stat .ez-lbl { display: block; margin-top: .15rem; font-size: .7rem; color: #64748b; }
|
||||
.ez-stat.is-active {
|
||||
border-color: #33b5d3; background: #f1fbff; box-shadow: inset 0 -3px 0 #33b5d3;
|
||||
}
|
||||
.ez-stat.is-active .ez-lbl { color: #0e7490; font-weight: 700; }
|
||||
|
||||
/* ---------- تفکیک تأخیر ---------- */
|
||||
.ez-legend {
|
||||
display: flex; flex-wrap: wrap; gap: .25rem .9rem;
|
||||
margin: .6rem 0 .5rem; font-size: .72rem; color: #64748b;
|
||||
}
|
||||
.ez-legend b { font-variant-numeric: tabular-nums; color: #334155; }
|
||||
|
||||
/* ---------- ابزارکها ---------- */
|
||||
.ez-toolbar {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin: 0 0 .75rem;
|
||||
}
|
||||
.ez-toolbar .button { margin: 0 !important; }
|
||||
.ez-seg {
|
||||
display: inline-flex; border: 1px solid #e2e8f0; border-radius: .5rem; overflow: hidden;
|
||||
}
|
||||
.ez-seg a {
|
||||
padding: .3rem .6rem; font-size: .72rem; color: #475569; text-decoration: none;
|
||||
background: #fff; border-inline-end: 1px solid #e2e8f0; white-space: nowrap;
|
||||
}
|
||||
.ez-seg a:last-child { border-inline-end: 0; }
|
||||
.ez-seg a:hover { background: #f1f5f9; }
|
||||
.ez-seg a.is-active { background: #33b5d3; color: #fff; font-weight: 700; }
|
||||
|
||||
/* ---------- جدول ---------- */
|
||||
.ez-table { width: 100%; margin: 0; font-size: .82rem; border-collapse: collapse; }
|
||||
@media (min-width: 900px) { .ez-table { table-layout: fixed; } }
|
||||
.ez-table thead th {
|
||||
position: sticky; top: 0; z-index: 2; background: #33b5d3; color: #06323d;
|
||||
font-size: .72rem; font-weight: 700; padding: .5rem .35rem; white-space: nowrap;
|
||||
border: 0; border-bottom: 1px solid #22a5c1;
|
||||
}
|
||||
.ez-table tbody td {
|
||||
padding: .45rem .3rem; vertical-align: middle; text-align: center;
|
||||
line-height: 1.4; border: 0; border-bottom: 1px solid #eef2f6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* تاریخها و شمارهها هرگز به دو خط نشکنند */
|
||||
.ez-table tbody td.ez-nowrap { white-space: nowrap; }
|
||||
.ez-table tbody tr:hover td { background: #f8fbfd; }
|
||||
|
||||
/* شمارندهٔ ردیف: بهجای یک ستون کامل، چیپ کنار نام */
|
||||
.ez-idx {
|
||||
display: inline-block; min-width: 1.55rem; margin-inline-end: .3rem;
|
||||
padding: .05rem .25rem; border-radius: .3rem; background: #f1f5f9;
|
||||
color: #64748b; font-size: .68rem; font-weight: 700;
|
||||
font-variant-numeric: tabular-nums; text-align: center;
|
||||
}
|
||||
.ez-name { font-weight: 700; font-size: .84rem; color: #0f172a; }
|
||||
.ez-name:hover { color: #0e7490; }
|
||||
.ez-sub { font-size: .68rem; color: #94a3b8; margin-top: .1rem; }
|
||||
|
||||
/* اعداد و شمارهها */
|
||||
.ez-num { font-variant-numeric: tabular-nums; }
|
||||
.ez-phone {
|
||||
direction: ltr; unicode-bidi: isolate;
|
||||
font-variant-numeric: tabular-nums; letter-spacing: .01em;
|
||||
}
|
||||
.ez-muted { color: #cbd5e1; }
|
||||
|
||||
.ez-badge {
|
||||
display: inline-block; padding: .1rem .45rem; border-radius: 999px;
|
||||
font-size: .68rem; font-weight: 700; white-space: nowrap; line-height: 1.6;
|
||||
}
|
||||
.ez-badge.danger { background: #fee2e2; color: #b91c1c; }
|
||||
.ez-badge.success { background: #dcfce7; color: #15803d; }
|
||||
.ez-badge.warning { background: #fef3c7; color: #b45309; }
|
||||
.ez-badge.mixed { background: #e0f2fe; color: #0369a1; }
|
||||
|
||||
/* تأخیر بهصورت چیپ (خوانا در یک نگاه) */
|
||||
.ez-late {
|
||||
display: inline-block; min-width: 4.4rem; padding: .1rem .4rem;
|
||||
border-radius: .3rem; font-size: .72rem; font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ez-late.lv0 { background: #f8fafc; color: #94a3b8; }
|
||||
.ez-late.lv1 { background: #fffbeb; color: #b45309; }
|
||||
.ez-late.lv2 { background: #fef2f2; color: #dc2626; }
|
||||
.ez-late.lv3 { background: #fde8e8; color: #991b1b; }
|
||||
|
||||
/* آدرس: حداکثر دو خط (بدون شکستن چیدمان) */
|
||||
.ez-addr {
|
||||
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
overflow: hidden; text-align: right; font-size: .78rem; color: #475569;
|
||||
}
|
||||
|
||||
/* ---------- حالت کارت (موبایل) ---------- */
|
||||
.ez-only-desktop { display: none !important; }
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.ez-table thead { display: none; }
|
||||
.ez-table, .ez-table tbody, .ez-table tr, .ez-table td { display: block; width: 100%; }
|
||||
.ez-table tbody tr {
|
||||
display: flex; flex-wrap: wrap; align-items: baseline;
|
||||
background: #fff; border: 1px solid #e5e7eb; border-radius: .625rem;
|
||||
margin-bottom: .5rem; padding: .55rem .7rem;
|
||||
}
|
||||
.ez-table tbody tr:hover td { background: transparent; }
|
||||
.ez-table tbody td {
|
||||
flex: 1 0 100%; display: flex; flex-wrap: wrap; align-items: baseline;
|
||||
gap: .3rem; padding: .12rem 0; border: 0; text-align: right;
|
||||
}
|
||||
.ez-table tbody td::before {
|
||||
content: attr(data-label); flex: 0 0 6.5rem;
|
||||
font-size: .7rem; color: #94a3b8;
|
||||
}
|
||||
/* سرِ کارت: نام مشتری، بدون برچسب */
|
||||
.ez-table tbody td.ez-card-head {
|
||||
padding-bottom: .35rem; margin-bottom: .25rem;
|
||||
border-bottom: 1px dashed #eef2f6;
|
||||
}
|
||||
.ez-table tbody td.ez-card-head::before,
|
||||
.ez-table tbody td.ez-nolabel::before { content: none; }
|
||||
.ez-actions { flex: 1 0 100%; display: flex; gap: .35rem; margin-top: .4rem; }
|
||||
.ez-actions .button { margin: 0 !important; flex: 1 1 0; }
|
||||
.ez-addr { -webkit-line-clamp: 3; }
|
||||
.ez-legend { font-size: .7rem; }
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.ez-table thead { display: table-header-group; }
|
||||
.ez-table tbody { display: table-row-group; }
|
||||
.ez-table tr { display: table-row; }
|
||||
.ez-table td { display: table-cell; }
|
||||
.ez-table td::before { content: none; }
|
||||
.ez-only-desktop { display: table-cell !important; }
|
||||
.ez-only-mobile { display: none !important; }
|
||||
.ez-actions { display: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="ez-wrap">
|
||||
<div class="columns m-0">
|
||||
<div class="column is-12 p-0">
|
||||
{# ===== نوار آمار: هر عدد لینک فیلتر ===== #}
|
||||
<div class="ez-stats">
|
||||
<a href="?view=all&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="ez-stat {% if view == 'all' %}is-active{% endif %}">
|
||||
<span class="ez-num">{{ counts.total }}</span>
|
||||
<span class="ez-lbl">کل معوق</span>
|
||||
</a>
|
||||
<a href="?view=uncoordinated&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="ez-stat {% if view == 'uncoordinated' %}is-active{% endif %}">
|
||||
<span class="ez-num has-text-danger">{{ counts.uncoordinated }}</span>
|
||||
<span class="ez-lbl">بدون قرار</span>
|
||||
</a>
|
||||
<a href="?view=coordinated&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="ez-stat {% if view == 'coordinated' %}is-active{% endif %}">
|
||||
<span class="ez-num has-text-success">{{ counts.coordinated }}</span>
|
||||
<span class="ez-lbl">با قرار</span>
|
||||
</a>
|
||||
<a href="?view=manual&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="ez-stat {% if view == 'manual' %}is-active{% endif %}">
|
||||
<span class="ez-num has-text-warning-dark">{{ counts.manual }}</span>
|
||||
<span class="ez-lbl">قرار هماهنگی</span>
|
||||
</a>
|
||||
<a href="?view=scheduled&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="ez-stat {% if view == 'scheduled' %}is-active{% endif %}">
|
||||
<span class="ez-num has-text-link">{{ counts.scheduled }}</span>
|
||||
<span class="ez-lbl">بازدید برنامهریزیشده</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# تفکیک مدت تأخیر در نمای جاری #}
|
||||
<div class="ez-legend">
|
||||
<span>تأخیر:</span>
|
||||
<span><b>{{ counts.buckets.lt30 }}</b> زیر ۳۰ روز</span>
|
||||
<span><b>{{ counts.buckets.30to90 }}</b> ۳۰–۹۰ روز</span>
|
||||
<span><b>{{ counts.buckets.90to180 }}</b> ۹۰–۱۸۰ روز</span>
|
||||
<span><b>{{ counts.buckets.gt180 }}</b> بیش از ۶ ماه</span>
|
||||
</div>
|
||||
|
||||
{# ===== ابزارکها: جستجو و مرتبسازی ===== #}
|
||||
<div class="ez-toolbar">
|
||||
<a href="#" id="openSearchBox" class="button is-info is-outlined is-rounded is-small">
|
||||
جستجو{% if searchVal %}: «{{ searchVal }}»{% endif %}
|
||||
</a>
|
||||
|
||||
<span class="is-size-7 has-text-grey">مرتبسازی</span>
|
||||
<span class="ez-seg">
|
||||
<a href="?view={{ view }}&sort=oldest{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="{% if sort == 'oldest' %}is-active{% endif %}">قدیمیترین</a>
|
||||
<a href="?view={{ view }}&sort=overdue{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="{% if sort == 'overdue' %}is-active{% endif %}">بیشترین تأخیر</a>
|
||||
<a href="?view={{ view }}&sort=newest{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
|
||||
class="{% if sort == 'newest' %}is-active{% endif %}">جدیدترین</a>
|
||||
</span>
|
||||
|
||||
{% if searchVal %}
|
||||
<a href="?view={{ view }}&sort={{ sort }}" class="button is-small is-light">حذف جستجو</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="table has-text-centered is-bordered is-striped is-narrow is-hoverable is-fullwidth">
|
||||
<!-- Your table content -->
|
||||
<table class="table ez-table">
|
||||
<colgroup>
|
||||
<col style="width: 23%">
|
||||
<col style="width: 13%">
|
||||
<col style="width: 10%">
|
||||
<col style="width: 11%">
|
||||
<col style="width: 10%">
|
||||
<col style="width: 9%">
|
||||
<col style="width: 11%">
|
||||
<col style="width: 13%">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr style="
|
||||
background: #33b5d3;
|
||||
">
|
||||
<th >ردیف</th>
|
||||
<th style="min-width: 200px;">نام و نام خانودگی</th>
|
||||
<th >آخرین بازدید</th>
|
||||
<th >تلفن</th>
|
||||
<th style="min-width: 400px;">آدرس</th>
|
||||
<tr>
|
||||
<th>نام و نام خانوادگی</th>
|
||||
<th>وضعیت قرار</th>
|
||||
<th>قرار هماهنگی</th>
|
||||
<th>بازدید برنامهریزیشده</th>
|
||||
<th>آخرین بازدید</th>
|
||||
<th>تأخیر</th>
|
||||
<th>تلفن</th>
|
||||
<th>آدرس</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for customer in customers %}
|
||||
<tr>
|
||||
<td>{{ forloop.counter }}</td>
|
||||
<td>
|
||||
{# سرِ ردیف: چیپ شماره + نام (شمارهٔ ردیف دیگر ستون جداگانه ندارد) #}
|
||||
<td data-label="نام" class="ez-card-head" style="text-align: right;">
|
||||
<input class="is-hidden fid fidv-20" value="20">
|
||||
<a target="_blank" href="{% url 'admin_panel:customerAction' customer.id %}" class="tdvl name">
|
||||
{% if customer.name_family|length >= 2 %} {{ customer.name_family }} {% else %} نامعلوم {% endif %}
|
||||
<span class="ez-idx">{{ forloop.counter0|add:customers.start_index }}</span>
|
||||
<a target="_blank" href="{% url 'admin_panel:customerAction' customer.id %}" class="tdvl name ez-name">
|
||||
{% if customer.name_family|length >= 2 %}{{ customer.name_family }}{% else %}نامعلوم{% endif %}
|
||||
</a>
|
||||
{% if customer.idCode %}<div class="ez-sub">کد: {{ customer.idCode }}</div>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="tdvl lastView">
|
||||
{{ customer.last_bazdid_dt | toJalali }}
|
||||
</div>
|
||||
{% with appt=appointment_rows|row_for:customer.id %}
|
||||
<td data-label="وضعیت" class="ez-nolabel">
|
||||
{% if appt.manual and appt.scheduled or appt.manual and appt.coordination %}
|
||||
<span class="ez-badge mixed">قرار + بازدید</span>
|
||||
{% elif appt.manual %}
|
||||
<span class="ez-badge warning">قرار هماهنگی</span>
|
||||
{% elif appt.scheduled or appt.coordination %}
|
||||
<span class="ez-badge success">بازدید برنامهریزیشده</span>
|
||||
{% else %}
|
||||
<span class="ez-badge danger">بدون قرار</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="قرار هماهنگی" class="ez-num ez-nowrap">
|
||||
{% if appt.manual %}{{ appt.manual|toJalali }}{% else %}<span class="ez-muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td data-label="بازدید برنامه" class="ez-num ez-nowrap">
|
||||
{% if appt.scheduled %}{{ appt.scheduled|toJalali }}
|
||||
{% elif appt.coordination %}{{ appt.coordination|toJalali }}<div class="ez-sub">در انتظار تماس</div>
|
||||
{% else %}<span class="ez-muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td data-label="آخرین بازدید" class="ez-num ez-nowrap lastView">
|
||||
{% if appt.last_visit %}{{ appt.last_visit|toJalali }}{% else %}<span class="ez-muted">بدون بازدید</span>{% endif %}
|
||||
</td>
|
||||
<td data-label="تأخیر" class="ez-nowrap">
|
||||
{% if appt.overdue_days >= 180 %}
|
||||
<span class="ez-late lv3">{{ appt.overdue_days }} روز</span>
|
||||
{% elif appt.overdue_days >= 90 %}
|
||||
<span class="ez-late lv2">{{ appt.overdue_days }} روز</span>
|
||||
{% elif appt.overdue_days >= 30 %}
|
||||
<span class="ez-late lv1">{{ appt.overdue_days }} روز</span>
|
||||
{% else %}
|
||||
<span class="ez-late lv0">{{ appt.overdue_days }} روز</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endwith %}
|
||||
|
||||
<td>
|
||||
<div class="tdvl mobile_1">
|
||||
{{ customer.phone1 }}
|
||||
</div>
|
||||
<div class="tdvl is-hidden mobile_2">
|
||||
09384999655
|
||||
</div>
|
||||
<div class="tdvl is-hidden mobile_3">
|
||||
{{ customer.phone2 }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="tdvl address">
|
||||
{{ customer.address }}
|
||||
<td data-label="تلفن" class="ez-nowrap">
|
||||
<span class="tdvl mobile_1 ez-phone">
|
||||
{% if customer.phone1 %}{{ customer.phone1 }}{% else %}<span class="ez-muted">—</span>{% endif %}
|
||||
</span>
|
||||
<span class="tdvl is-hidden mobile_2">{{ customer.phone2 }}</span>
|
||||
<span class="tdvl is-hidden mobile_3">{{ customer.phone3 }}</span>
|
||||
{# دکمههای اقدام سریع — فقط موبایل #}
|
||||
<div class="ez-actions">
|
||||
{% if customer.phone1 %}
|
||||
<a href="tel:{{ customer.phone1 }}" class="button is-success is-small">تماس</a>
|
||||
{% endif %}
|
||||
<a target="_blank" href="{% url 'admin_panel:customerAction' customer.id %}" class="button is-info is-small">ثبت اقدام</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<td data-label="آدرس">
|
||||
<div class="tdvl address ez-addr" title="{{ customer.address }}">{{ customer.address }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="8" class="has-text-centered py-5">
|
||||
<span class="has-text-grey">موردی در این نما پیدا نشد.</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="">
|
||||
{% if customers.has_previous %}
|
||||
<a class="button is-danger" href="?{% if searchVal %}searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}&page={{ customers.previous_page_number }}">صفحه قبل</a>
|
||||
{% endif %}
|
||||
|
||||
{% if customers.has_next %}
|
||||
<a class="button is-danger" href="?{% if searchVal %}searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}&page={{ customers.next_page_number }}">صفحه بعد</a>
|
||||
{# ===== صفحهبندی ===== #}
|
||||
<div class="is-flex is-align-items-center is-justify-content-space-between mt-3 mb-5">
|
||||
{% if customers.has_previous %}
|
||||
<a class="button is-danger is-small" href="?view={{ view }}&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}&page={{ customers.previous_page_number }}">صفحه قبل</a>
|
||||
{% else %}<span></span>{% endif %}
|
||||
|
||||
{% endif %}
|
||||
<span class="is-size-7 has-text-grey">
|
||||
صفحهٔ {{ customers.number }} از {{ customers.paginator.num_pages }} ·
|
||||
{{ customers.paginator.count }} مورد
|
||||
</span>
|
||||
|
||||
</div>
|
||||
{% if customers.has_next %}
|
||||
<a class="button is-danger is-small" href="?view={{ view }}&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}&page={{ customers.next_page_number }}">صفحه بعد</a>
|
||||
{% else %}<span></span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal SearchBox">
|
||||
<div class="modal-background closeSearch"></div>
|
||||
<div class="modal-content">
|
||||
<!-- -->
|
||||
<form method="get" class="columns is-mobile is-multiline">
|
||||
{# حفظ نما و ترتیب هنگام جستوجو #}
|
||||
<input type="hidden" name="view" value="{{ view }}">
|
||||
<input type="hidden" name="sort" value="{{ sort }}">
|
||||
<div class="field column is-7-desktop is-12-mobile m-0">
|
||||
<label class="label">جستجو کنید</label>
|
||||
<div class="control">
|
||||
<input name="searchVal" class="input search" type="text" placeholder="">
|
||||
<input name="searchVal" class="input search" type="text" value="{{ searchVal }}" placeholder="نام، تلفن، کد یا آدرس">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-5-desktop is-12-mobile m-0">
|
||||
<label class="label">فیلد</label>
|
||||
<div class="select">
|
||||
<div class="select is-fullwidth">
|
||||
<select name="searchOn">
|
||||
<!--
|
||||
<option value="name">نام و نام خانوادگی</option>
|
||||
<option value="code">کدمشتری</option>
|
||||
<option value="phone">تلفن</option> !-->
|
||||
<option value="address">آدرس</option>
|
||||
<option value="name" {% if searchOn == 'name' %}selected{% endif %}>نام و نام خانوادگی</option>
|
||||
<option value="phone" {% if searchOn == 'phone' %}selected{% endif %}>تلفن</option>
|
||||
<option value="address" {% if searchOn == 'address' %}selected{% endif %}>آدرس</option>
|
||||
<option value="code" {% if searchOn == 'code' %}selected{% endif %}>کد مشتری</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-12-desktop is-12-mobile m-0">
|
||||
<input class="button is-success" id="searchbtn" value="جستجو مشتری" type="submit">
|
||||
<input class="button is-success is-fullwidth" id="searchbtn" value="جستجو مشتری" type="submit">
|
||||
</div>
|
||||
<!-- -->
|
||||
</form>
|
||||
|
||||
|
||||
@@ -112,109 +382,17 @@
|
||||
<button class="modal-close is-large closeSearch" aria-label="close"></button>
|
||||
</div>
|
||||
|
||||
<div class="modal BazdidF">
|
||||
<div class="modal-background closeBazdidF"></div>
|
||||
<div class="modal-content">
|
||||
<div class="columns is-mobile is-multiline">
|
||||
|
||||
<div class="field column is-12-desktop is-12-mobile m-0">
|
||||
<label class="label">نام ونام خانوادگی</label>
|
||||
<div class="control">
|
||||
<input class="input rf_name" id="bz_name" type="text" placeholder="نام نام خانوادگی">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-4-desktop is-12-mobile m-0">
|
||||
<label class="label">تلفن همراه</label>
|
||||
<div class="control">
|
||||
<input class="input rf_mobile" id="phone-1" type="text" placeholder="">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-4-desktop is-12-mobile m-0">
|
||||
<label class="label">تاریخ</label>
|
||||
<div class="control">
|
||||
<input name="bazdidDateTime" id="persianDateP" class="input bz_DateTime" type="text" placeholder="">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="field column is-4-desktop is-12-mobile m-0">
|
||||
<label class="label">توسط</label>
|
||||
<div class="select">
|
||||
<select>
|
||||
<option>کدام خدمات؟</option>
|
||||
<option>خدمات 1</option>
|
||||
<option>خدمات 2</option>
|
||||
<option>خدمات 3</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-4-desktop is-12-mobile m-0">
|
||||
<label class="label">زمان مراجعه</label>
|
||||
<div class="select">
|
||||
<select>
|
||||
<option>زمان را انتخاب کنید</option>
|
||||
<option>صبح</option>
|
||||
<option>ظهر</option>
|
||||
<option>بعد از ظهر</option>
|
||||
<option>غروب</option>
|
||||
<option>شب</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-8-desktop is-12-mobile m-0">
|
||||
<label class="label">توضیحات</label>
|
||||
<div class="control">
|
||||
<textarea class="textarea" placeholder="توضیحات"></textarea> </div>
|
||||
</div>
|
||||
|
||||
<div class="field column is-12-desktop is-12-mobile m-0">
|
||||
<input class="button is-success" id="editCS" value="ایجاد بازدید" type="submit">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<button class="modal-close is-large closeBazdidF" aria-label="close"></button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block jsEnd %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
|
||||
kamaDatepicker('persianDateP', { buttonsColor: "red", forceFarsiDigits: true });
|
||||
|
||||
$(".closeBazdidF").click(function(){
|
||||
$(".BazdidF").removeClass("is-active")
|
||||
$("#openSearchBox").click(function(e) {
|
||||
e.preventDefault();
|
||||
$(".SearchBox").addClass("is-active");
|
||||
});
|
||||
|
||||
$("#openSearchBox").click(function(){
|
||||
$(".SearchBox").addClass("is-active")
|
||||
$(".closeSearch").click(function() {
|
||||
$(".SearchBox").removeClass("is-active");
|
||||
});
|
||||
|
||||
$(".closeSearch").click(function(){
|
||||
$(".SearchBox").removeClass("is-active")
|
||||
});
|
||||
|
||||
$(".openRow").click(function(){
|
||||
var id = $(this).parent().parent().find('.fid').val().trim();
|
||||
var name = $(this).parent().parent().find('.name').text().trim();
|
||||
var mobile_1 = $(this).parent().parent().find('.mobile_1').text().trim();
|
||||
|
||||
var address = $(this).parent().parent().find('.address').text().trim();
|
||||
//alert(id)
|
||||
$(".BazdidF").find('#bz_name').val(name);
|
||||
$(".BazdidF").find('#phone-1').val(mobile_1);
|
||||
|
||||
//$("#bedehiStatus").val('وضعیت')
|
||||
|
||||
$(".BazdidF").addClass("is-active")
|
||||
});
|
||||
} );
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -20,6 +20,20 @@ def toJalaliDT(value):
|
||||
def has_group(user, group_name):
|
||||
return user.groups.filter(name=group_name).exists()
|
||||
|
||||
|
||||
def row_for(mapping, key):
|
||||
"""مقدار یک نگاشت با کلید دلخواه (برای دسترسی به دیکشنری در قالب).
|
||||
|
||||
``{{ appointment_rows|row_for:customer.id }}`` ⇒ مقدار مربوط به همان مشتری،
|
||||
یا ``None`` اگر نبود. (جایگزین سبکِ ``.get()`` در قالبهای Django.)
|
||||
"""
|
||||
if not mapping:
|
||||
return None
|
||||
try:
|
||||
return mapping.get(key)
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
@register.simple_tag
|
||||
def customer_phones(customer):
|
||||
"""شمارههای مشتری برای ستون اطلاعات مشتری.
|
||||
@@ -35,4 +49,5 @@ def customer_phones(customer):
|
||||
|
||||
register.filter('has_group', has_group)
|
||||
register.filter('toJalali', toJalali)
|
||||
register.filter('toJalaliDT', toJalaliDT)
|
||||
register.filter('toJalaliDT', toJalaliDT)
|
||||
register.filter('row_for', row_for)
|
||||
@@ -12,8 +12,10 @@ from django.utils import timezone
|
||||
from ..models import Action, Customer
|
||||
from ..sms.triggers import notify_visit_scheduled
|
||||
from ..visit_policy import (REASON_CANCEL, REASON_CREATE, REASON_EDIT,
|
||||
as_aware, normalize_finish_datetime,
|
||||
refresh_last_bazdid, validate_action_payload)
|
||||
as_aware, clear_visit_hold, default_hold_until,
|
||||
normalize_finish_datetime, normalize_hold_until,
|
||||
refresh_last_bazdid, set_visit_hold,
|
||||
validate_action_payload)
|
||||
|
||||
|
||||
@login_required(login_url='/panel/p_login/')
|
||||
@@ -80,6 +82,8 @@ def _action_payload_from_request(request):
|
||||
'status': Action.Status.FINISH if is_done else Action.Status.WAITE,
|
||||
'actionDateTime': _parse_form_datetime(request.POST.get('dateTimeAnjam')),
|
||||
'finishDateTime': _parse_form_datetime(request.POST.get('FinishDateTime')),
|
||||
# فقط برای «هماهنگی» معنا دارد؛ تاریخِ «تا کِی پیگیری نشود».
|
||||
'holdRaw': _parse_form_datetime(request.POST.get('visitHoldUntil')),
|
||||
'desc': request.POST.get('desc', None),
|
||||
'result': request.POST.get('result', None),
|
||||
'userID': request.POST.get('userID', None),
|
||||
@@ -87,6 +91,26 @@ def _action_payload_from_request(request):
|
||||
}
|
||||
|
||||
|
||||
def _apply_visit_hold(customer_obj, payload):
|
||||
"""نوشتن/پاککردن «قرار بعدی» بر اساس اقدام ثبت/ویرایششده.
|
||||
|
||||
قواعد:
|
||||
* فقط برای اقدام نوع «هماهنگی» معنا دارد.
|
||||
* اگر تاریخ قرار داده شده باشد ⇒ ``set_visit_hold`` (فقط رو به جلو).
|
||||
* اگر هماهنگی «انجام شد» تیک خورده و تاریخ قرار خالی است ⇒ پیشفرض ۱۴ روز.
|
||||
* اگر هماهنگی لغو شد (``cancell``) یا نوع عوض شد ⇒ قرار پاک میشود.
|
||||
"""
|
||||
if payload['actionType'] != Action.ActionType.HAMAHANGI:
|
||||
return
|
||||
raw = payload.get('holdRaw')
|
||||
if raw is not None:
|
||||
target = normalize_hold_until(raw)
|
||||
set_visit_hold(customer_obj, target)
|
||||
return
|
||||
if payload['status'] == Action.Status.FINISH:
|
||||
set_visit_hold(customer_obj, default_hold_until())
|
||||
|
||||
|
||||
def _reject(request, error, redirect_to):
|
||||
"""پیام فارسی خطا به کاربر و بازگشت بدون ذخیرهٔ چیزی."""
|
||||
messages.error(request, '؛ '.join(error.messages))
|
||||
@@ -98,6 +122,9 @@ def customerNewAction(request ,c_id):
|
||||
payload = _action_payload_from_request(request)
|
||||
try:
|
||||
validate_action_payload(payload)
|
||||
# اعتبارسنجی «قرار بعدی» پیش از هر نوشتنی (سقف ۶ ماه و تاریخ گذشته).
|
||||
if payload['actionType'] == Action.ActionType.HAMAHANGI and payload['holdRaw']:
|
||||
normalize_hold_until(payload['holdRaw'])
|
||||
except ValidationError as error:
|
||||
return _reject(request, error, '/panel/customer/' + str(c_id) + '/action')
|
||||
|
||||
@@ -119,6 +146,8 @@ def customerNewAction(request ,c_id):
|
||||
|
||||
# last_bazdid_dt فقط از تابع واحد بازمحاسبه میشود؛ نه از finishDateTime.
|
||||
refresh_last_bazdid(customer_obj, reason=REASON_CREATE, user=request.user)
|
||||
# «قرار بعدی» فقط برای هماهنگی.
|
||||
_apply_visit_hold(customer_obj, payload)
|
||||
|
||||
# پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
|
||||
if newAction.actionType == Action.ActionType.BAZDID:
|
||||
@@ -147,6 +176,8 @@ def customerEditAction(request ,c_id, action_id):
|
||||
payload = _action_payload_from_request(request)
|
||||
try:
|
||||
validate_action_payload(payload)
|
||||
if payload['actionType'] == Action.ActionType.HAMAHANGI and payload['holdRaw']:
|
||||
normalize_hold_until(payload['holdRaw'])
|
||||
except ValidationError as error:
|
||||
return _reject(
|
||||
request, error,
|
||||
@@ -174,6 +205,12 @@ def customerEditAction(request ,c_id, action_id):
|
||||
# (تا مقداری که این رکورد ساخته بود با منبع حقیقت همراستا شود).
|
||||
reason = REASON_CANCEL if (was_done and not payload['is_done']) else REASON_EDIT
|
||||
refresh_last_bazdid(customer_obj, reason=reason, user=request.user)
|
||||
# «قرار بعدی»: با ویرایش همراستا میشود؛ اگر اقدام دیگر هماهنگی نباشد یا
|
||||
# هماهنگی لغو شود، قرار دستی پاک میشود.
|
||||
if payload['actionType'] == Action.ActionType.HAMAHANGI and newAction.status != Action.Status.CANCELL:
|
||||
_apply_visit_hold(customer_obj, payload)
|
||||
else:
|
||||
clear_visit_hold(customer_obj)
|
||||
|
||||
#return redirect('/panel/customer/'+str(c_id)+'/action')
|
||||
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
|
||||
|
||||
+173
-20
@@ -2,7 +2,7 @@ import datetime
|
||||
import os
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Sum, Q
|
||||
from django.db.models import Sum, Q, Case, When, Value
|
||||
from django.shortcuts import redirect, render, get_object_or_404
|
||||
from django.core.paginator import Paginator
|
||||
from django.utils import timezone
|
||||
@@ -18,7 +18,19 @@ from ..sms.phones import (normalize_primary_values,
|
||||
save_primary_normalized)
|
||||
from ..sms.triggers import notify_payment_thanks, notify_welcome
|
||||
from ..sms.rules import customer_toggle_map
|
||||
from ..visit_policy import (last_visit_map, next_appointment_map, visit_due_at)
|
||||
from ..visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED,
|
||||
appointment_sources_map, last_visit_map, visit_due_at,
|
||||
visit_due_date, visit_period_months)
|
||||
|
||||
# نماهای صفحهٔ «انتظار» (فیلتر ?view=). پیشفرض = فقط «بدون قرار» تا رفتار
|
||||
# قبلی صفحه برای کاربران حفظ شود.
|
||||
ENTEZAR_VIEWS = ('uncoordinated', 'manual', 'scheduled', 'coordinated', 'all')
|
||||
ENTEZAR_DEFAULT_VIEW = 'uncoordinated'
|
||||
# حالتهای مرتبسازی (?sort=):
|
||||
# oldest ⇒ قدیمیترین بازدید اول (پیشفرض/اولویت کارشناس)
|
||||
# newest ⇒ جدیدترین بازدید اول
|
||||
# overdue ⇒ بیشترین تأخیر اول
|
||||
ENTEZAR_SORTS = ('oldest', 'newest', 'overdue')
|
||||
from ._helpers import logger, _parse_jalali_date, _parse_phone_rows
|
||||
|
||||
|
||||
@@ -329,32 +341,59 @@ def customerEdit(request, c_id):
|
||||
|
||||
@login_required(login_url='/panel/p_login/')
|
||||
def entezar(request):
|
||||
"""فهرست «منتظر بازدید».
|
||||
"""فهرست «منتظر بازدید» + تفکیک «با قرار» و «بدون قرار».
|
||||
|
||||
منبع حقیقت تاریخ بازدید، ``visit_policy.last_visit_map`` است (نه ستون کششدهٔ
|
||||
``last_bazdid_dt``) تا این صفحه و اسکنر پیامک هرگز واگرا نشوند. دورهٔ بازدید هم
|
||||
از همان قالب پیامک خوانده میشود. مشتری دارای «تعویق فعال» (اقدام هماهنگی
|
||||
در انتظار با تاریخ آینده) از فهرست خارج و به صف خدمات پس از فروش سپرده میشود.
|
||||
منبع حقیقت تاریخ بازدید ``visit_policy.last_visit_map`` است و منبع «قرار بعدی»
|
||||
``appointment_sources_map`` (سه منبع: قرار دستی، بازدید برنامهریزیشده،
|
||||
هماهنگیِ در انتظار). هر مشتری دقیقاً در یکی از چهار گروه زیر است:
|
||||
|
||||
* ``A`` — معوق بدون هیچ قرار (پیشفرض نمایش؛ رفتار قبلی صفحه)
|
||||
* ``B`` — معوق با قرار دستی (تعویق از فرم هماهنگی)
|
||||
* ``C`` — معوق با بازدید برنامهریزیشده یا هماهنگی در انتظار
|
||||
* ``D`` — معوق با بیش از یک منبعِ قرار
|
||||
|
||||
فیلتر با ``?view=`` و قابل ترکیب با جستوجوی آدرس است.
|
||||
"""
|
||||
searchVal = request.GET.get('searchVal', '')
|
||||
searchOn = request.GET.get('searchOn', '')
|
||||
view = (request.GET.get('view') or ENTEZAR_DEFAULT_VIEW).strip().lower()
|
||||
if view not in ENTEZAR_VIEWS:
|
||||
view = ENTEZAR_DEFAULT_VIEW # مقدار نامعتبر ⇒ پیشفرض، بدون خطا
|
||||
sort = (request.GET.get('sort') or 'oldest').strip().lower()
|
||||
if sort not in ENTEZAR_SORTS:
|
||||
sort = 'oldest'
|
||||
searchStatus = True
|
||||
full_path = request.get_full_path()
|
||||
|
||||
now = timezone.now()
|
||||
due_cut = visit_due_at(now=now)
|
||||
# دوره فقط یک بار خوانده میشود (جلوگیری از N+1 در حلقهٔ محاسبهٔ تأخیر).
|
||||
period_months = visit_period_months()
|
||||
# مرز تاریخ به وقت محلی (نه UTC) تا صفحه و اسکنر پیامک یک جواب بدهند.
|
||||
due_cut_date = due_cut.astimezone(timezone.get_current_timezone()).date()
|
||||
|
||||
visits = last_visit_map(now=now)
|
||||
appointments = next_appointment_map(now=now)
|
||||
sources = appointment_sources_map(now=now)
|
||||
|
||||
customers_objj = Customer.objects.filter(
|
||||
laghv_bazdid=False, name_family__isnull=False,
|
||||
installDate__lte=due_cut_date,
|
||||
)
|
||||
if searchOn == 'address':
|
||||
customers_objj = customers_objj.filter(address__contains=searchVal)
|
||||
# جستوجو: نام، آدرس، تلفن (همه شمارهها) و کد شناسایی.
|
||||
if searchVal:
|
||||
if searchOn == 'address':
|
||||
customers_objj = customers_objj.filter(address__contains=searchVal)
|
||||
elif searchOn == 'name':
|
||||
customers_objj = customers_objj.filter(name_family__contains=searchVal)
|
||||
elif searchOn == 'phone':
|
||||
customers_objj = customers_objj.filter(
|
||||
Q(phone1__contains=searchVal) | Q(phone2__contains=searchVal)
|
||||
| Q(phone3__contains=searchVal)
|
||||
)
|
||||
elif searchOn == 'code':
|
||||
customers_objj = customers_objj.filter(idCode__contains=searchVal)
|
||||
else:
|
||||
searchStatus = False
|
||||
else:
|
||||
searchStatus = False
|
||||
|
||||
@@ -363,28 +402,142 @@ def entezar(request):
|
||||
customer_id for customer_id in customers_objj.values_list('id', flat=True)
|
||||
if visits.get(customer_id) is None or visits[customer_id] <= due_cut
|
||||
]
|
||||
customers_objj = customers_objj.filter(id__in=stale_ids)
|
||||
|
||||
# «قرار بعدی» (هماهنگی توافقشده یا بازدید برنامهریزیشده) یعنی این مشتری
|
||||
# در این فهرست جایی ندارد تا آن تاریخ.
|
||||
pending_ids = [cid for cid in stale_ids if cid in appointments]
|
||||
if pending_ids:
|
||||
customers_objj = customers_objj.exclude(id__in=pending_ids)
|
||||
# تفکیک چهارگروهی (MECE): هر مشتری فقط در یک گروه.
|
||||
groups = {'A': [], 'B': [], 'C': [], 'D': []}
|
||||
for customer_id in stale_ids:
|
||||
customer_sources = sources.get(customer_id) or {}
|
||||
manual = SOURCE_MANUAL in customer_sources
|
||||
scheduled = (SOURCE_SCHEDULED in customer_sources
|
||||
or SOURCE_COORDINATION in customer_sources)
|
||||
if manual and scheduled:
|
||||
groups['D'].append(customer_id)
|
||||
elif manual:
|
||||
groups['B'].append(customer_id)
|
||||
elif scheduled:
|
||||
groups['C'].append(customer_id)
|
||||
else:
|
||||
groups['A'].append(customer_id)
|
||||
|
||||
customers_objj = customers_objj.order_by('last_bazdid_dt', 'id')
|
||||
selected = {
|
||||
'uncoordinated': groups['A'],
|
||||
'manual': groups['B'] + groups['D'],
|
||||
'scheduled': groups['C'] + groups['D'],
|
||||
'coordinated': groups['B'] + groups['C'] + groups['D'],
|
||||
'all': stale_ids,
|
||||
}[view]
|
||||
|
||||
# «مدت تأخیر» = از چه زمانی بازدید عقب افتاده است (به روز).
|
||||
# مبنای محاسبه «موعد بازدید» است، نه خود بازدید: موعد = بازدید + دوره.
|
||||
# اینطور مشتریِ تازهمعوق هم در بازهٔ کوتاه قرار میگیرد و عدد معنا پیدا میکند.
|
||||
today = now.astimezone(timezone.get_current_timezone()).date()
|
||||
overdue_days = {}
|
||||
for customer_id in selected:
|
||||
anchor = visits.get(customer_id)
|
||||
if anchor is not None:
|
||||
due = visit_due_date(anchor, months=period_months)
|
||||
base = due.astimezone(timezone.get_current_timezone()).date()
|
||||
else:
|
||||
# بدون بازدید: مقطع دوره (تاریخ نصب خیلی قدیمی، عدد را بیمعنا میکند).
|
||||
base = due_cut_date
|
||||
overdue_days[customer_id] = max((today - base).days, 0)
|
||||
|
||||
# مرتبسازی:
|
||||
# oldest ⇒ قدیمیترین بازدید اول (اولویت کارشناس)
|
||||
# newest ⇒ جدیدترین بازدید اول
|
||||
# overdue ⇒ بیشترین تأخیر اول (چه کسی بیشتر از همه عقب افتاده)
|
||||
if sort == 'overdue':
|
||||
ordered_ids = sorted(selected, key=lambda cid: (-overdue_days.get(cid, 0), cid))
|
||||
customers_objj = customers_objj.filter(id__in=ordered_ids)
|
||||
if ordered_ids:
|
||||
# ترتیب در لایهٔ دیتابیس با CASE/WHEN (بدون بار حافظهای).
|
||||
ordering = Case(
|
||||
*[When(id=cid, then=Value(index))
|
||||
for index, cid in enumerate(ordered_ids)],
|
||||
output_field=models.IntegerField(),
|
||||
)
|
||||
customers_objj = customers_objj.order_by(ordering)
|
||||
else:
|
||||
customers_objj = customers_objj.order_by('id')
|
||||
else:
|
||||
# مرتبسازی بر مبنای **تاریخ مؤثر بازدید** (از Action)، نه ستون ممکنالقدمت.
|
||||
ordered_ids = sorted(
|
||||
selected,
|
||||
key=lambda cid: (
|
||||
(visits.get(cid) or due_cut),
|
||||
cid,
|
||||
),
|
||||
reverse=(sort == 'newest'),
|
||||
)
|
||||
customers_objj = customers_objj.filter(id__in=ordered_ids)
|
||||
ordering = Case(
|
||||
*[When(id=cid, then=Value(index))
|
||||
for index, cid in enumerate(ordered_ids)],
|
||||
output_field=models.IntegerField(),
|
||||
)
|
||||
customers_objj = customers_objj.order_by(ordering)
|
||||
|
||||
# آمار «تأخیر» (روی کل گروه انتخابی، نه فقط صفحهٔ جاری).
|
||||
buckets = {'lt30': 0, '30to90': 0, '90to180': 0, 'gt180': 0}
|
||||
for days in overdue_days.values():
|
||||
if days < 30:
|
||||
buckets['lt30'] += 1
|
||||
elif days < 90:
|
||||
buckets['30to90'] += 1
|
||||
elif days < 180:
|
||||
buckets['90to180'] += 1
|
||||
else:
|
||||
buckets['gt180'] += 1
|
||||
|
||||
counts = {
|
||||
'total': len(stale_ids),
|
||||
'uncoordinated': len(groups['A']),
|
||||
'manual': len(groups['B']),
|
||||
'scheduled': len(groups['C']),
|
||||
'both': len(groups['D']),
|
||||
'coordinated': len(groups['B']) + len(groups['C']) + len(groups['D']),
|
||||
'selected': len(selected),
|
||||
'buckets': buckets,
|
||||
}
|
||||
|
||||
try:
|
||||
entezar_bazdid = CacheTable.objects.get(key='9mahbazdid')
|
||||
entezar_bazdid.value = len(stale_ids) - len(pending_ids)
|
||||
# کارت داشبورد = «کارِ فوری» = بدون قرار.
|
||||
entezar_bazdid.value = counts['uncoordinated']
|
||||
entezar_bazdid.save()
|
||||
except CacheTable.DoesNotExist:
|
||||
pass
|
||||
|
||||
paginator = Paginator(customers_objj, 20) # Show 25 contacts per page.
|
||||
paginator = Paginator(customers_objj, 20)
|
||||
page_number = request.GET.get('page', None)
|
||||
customerHa = paginator.get_page(page_number)
|
||||
|
||||
return render(request, 'admin_panel/entezar.html', {'customers': customerHa, 'searchStatus': searchStatus, 'full_path': full_path, 'searchVal': searchVal, 'searchOn':searchOn})
|
||||
# اطلاعات هر ردیف برای قالب: قرارها + مدت تأخیر + آخرین بازدید مؤثر.
|
||||
appointment_rows = {}
|
||||
for customer in customerHa.object_list:
|
||||
entry = sources.get(customer.id) or {}
|
||||
appointment_rows[customer.id] = {
|
||||
'manual': entry.get(SOURCE_MANUAL),
|
||||
'scheduled': entry.get(SOURCE_SCHEDULED),
|
||||
'coordination': entry.get(SOURCE_COORDINATION),
|
||||
'overdue_days': overdue_days.get(customer.id, 0),
|
||||
'last_visit': visits.get(customer.id),
|
||||
'group': (
|
||||
'D' if (SOURCE_MANUAL in entry and (
|
||||
SOURCE_SCHEDULED in entry or SOURCE_COORDINATION in entry))
|
||||
else 'B' if SOURCE_MANUAL in entry
|
||||
else 'C' if (SOURCE_SCHEDULED in entry or SOURCE_COORDINATION in entry)
|
||||
else 'A'
|
||||
),
|
||||
}
|
||||
|
||||
return render(request, 'admin_panel/entezar.html', {
|
||||
'customers': customerHa, 'searchStatus': searchStatus, 'full_path': full_path,
|
||||
'searchVal': searchVal, 'searchOn': searchOn,
|
||||
'view': view, 'counts': counts, 'appointment_rows': appointment_rows,
|
||||
'sort': sort, 'sorts': ENTEZAR_SORTS,
|
||||
'views': ENTEZAR_VIEWS,
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url='/panel/p_login/')
|
||||
|
||||
+238
-47
@@ -295,57 +295,23 @@ def refresh_last_bazdid(customer, *, reason=REASON_EDIT, user=None, now=None):
|
||||
|
||||
|
||||
def next_appointment_map(*, now=None):
|
||||
"""نگاشت ``customer_id → زودترین «قرار بعدی» آینده`` — با تجمیع در دیتابیس.
|
||||
"""نگاشت ``customer_id → زودترین «قرار بعدی» آینده``.
|
||||
|
||||
منابع قرار (فقط آینده):
|
||||
* ``hamahangi`` با وضعیت ``waite`` **یا** ``finish``،
|
||||
* ``bazdid`` با وضعیت ``waite`` (بازدید برنامهریزیشده).
|
||||
اجتماع سه منبع (همه با تاریخ آینده):
|
||||
|
||||
تاریخ قرار = ``actionDateTime`` و اگر خالی بود ``finishDateTime``.
|
||||
فقط برای ``entezar`` و اسکنر پیامک استفاده میشود و هرگز در «آخرین بازدید»
|
||||
دخالت نمیکند.
|
||||
* ``manual`` — ستون ``Customer.visit_hold_until`` (با اعتبارسنجی)،
|
||||
* ``scheduled`` — ``bazdid + waite`` (بازدید برنامهریزیشده)،
|
||||
* ``coordination`` — ``hamahangi`` با وضعیت ``waite`` یا ``finish`` که
|
||||
تاریخش آینده است (سازگاری با داده و رفتار قبلی).
|
||||
|
||||
این تابع تصمیم «مشتری در فهرست انتظار هست یا نه» و همچنین اسکنر پیامک را
|
||||
تغذیه میکند؛ «آخرین بازدید» هیچگاه از اینجا خوانده نمیشود.
|
||||
"""
|
||||
now = _now(now)
|
||||
# همان نکتهٔ ``last_visit_map``: ساعت مرجع باید aware بماند تا Django آن را
|
||||
# دوباره با منطقهٔ محلی adapt نکند.
|
||||
now_utc = now.astimezone(dt_timezone.utc)
|
||||
|
||||
# چرا دو کوئری جدا؟ با یک شرط ``OR`` روی نوع اقدام، بهینهساز SQLite از ایندکس
|
||||
# ``act_visit_idx`` استفاده نمیکند و کوئری ~۱۹۳ms طول میکشد. با تقسیم به
|
||||
# کوئریهای ساده، هر کدام دقیقاً با ایندکس (actionType + status) اجرا میشوند.
|
||||
# (از ``union()`` استفاده نمیکنیم چون در این ترکیب، ردیفها را از دست میدهد.)
|
||||
hamahangi = Action.objects.filter(
|
||||
actionType=Action.ActionType.HAMAHANGI,
|
||||
status__in=(Action.Status.WAITE, Action.Status.FINISH),
|
||||
).filter(
|
||||
models.Q(actionDateTime__gt=now_utc)
|
||||
| models.Q(actionDateTime__isnull=True, finishDateTime__gt=now_utc)
|
||||
).values_list("customer_id", "actionDateTime", "finishDateTime")
|
||||
|
||||
planned_visit = Action.objects.filter(
|
||||
actionType=Action.ActionType.BAZDID,
|
||||
status=Action.Status.WAITE,
|
||||
actionDateTime__gt=now_utc,
|
||||
).values_list("customer_id", "actionDateTime")
|
||||
|
||||
combined = {}
|
||||
|
||||
def _remember(customer_id, when):
|
||||
when = as_aware(when)
|
||||
if when is None:
|
||||
return
|
||||
current = combined.get(customer_id)
|
||||
if current is None or when < current:
|
||||
combined[customer_id] = when
|
||||
|
||||
for customer_id, action_dt, finish_dt in hamahangi.iterator():
|
||||
# تاریخ قرار: تاریخ اقدام؛ و برای هماهنگیِ بدون تاریخ اقدام، تاریخ اتمام.
|
||||
_remember(customer_id, action_dt or finish_dt)
|
||||
|
||||
for customer_id, action_dt in planned_visit.iterator():
|
||||
_remember(customer_id, action_dt)
|
||||
|
||||
return combined
|
||||
return {
|
||||
customer_id: min(sources.values())
|
||||
for customer_id, sources in appointment_sources_map(now=now).items()
|
||||
}
|
||||
|
||||
|
||||
def next_appointment(customer, *, now=None):
|
||||
@@ -355,6 +321,134 @@ def next_appointment(customer, *, now=None):
|
||||
return next_appointment_map(now=now).get(customer.pk)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# منابع «قرار بعدی» بهتفکیک — برای صفحهٔ انتظار و آمار
|
||||
# ---------------------------------------------------------------------------
|
||||
# سه منبع وجود دارد و هر سه با هم «قرار بعدی» را میسازند:
|
||||
#
|
||||
# ۱. ``manual`` — ستون ``Customer.visit_hold_until`` (تعویق دستی از فرم هماهنگی)
|
||||
# ۲. ``scheduled`` — اقدام بازدید با وضعیت ``waite`` و تاریخ آینده
|
||||
# ۳. ``coordination`` — اقدام هماهنگیِ در انتظار (کارشناس تیک «انجام شد» نزده)
|
||||
#
|
||||
# تفکیک اینها فقط برای نمایش/آمار است؛ تصمیم «مشتری در فهرست هست یا نه» را
|
||||
# همان ``next_appointment_map`` میگیرد (اجتماع هر سه).
|
||||
|
||||
SOURCE_MANUAL = "manual"
|
||||
SOURCE_SCHEDULED = "scheduled"
|
||||
SOURCE_COORDINATION = "coordination"
|
||||
|
||||
|
||||
def _future_actions(action_type, statuses, *, now_utc):
|
||||
"""اقدامهای آیندهٔ یک نوع با تاریخ مؤثر (تاریخ اقدام، وگرنه تاریخ اتمام)."""
|
||||
return (
|
||||
Action.objects.filter(actionType=action_type, status__in=statuses)
|
||||
.filter(
|
||||
models.Q(actionDateTime__gt=now_utc)
|
||||
| models.Q(actionDateTime__isnull=True, finishDateTime__gt=now_utc)
|
||||
)
|
||||
.values_list("customer_id", "actionDateTime", "finishDateTime")
|
||||
)
|
||||
|
||||
|
||||
def _action_appointments(action_type, statuses, *, now=None):
|
||||
"""نگاشت ``customer_id → زودترین تاریخِ آینده`` برای یک نوع اقدام."""
|
||||
now_utc = _now(now).astimezone(dt_timezone.utc)
|
||||
result = {}
|
||||
for customer_id, action_dt, finish_dt in _future_actions(
|
||||
action_type, statuses, now_utc=now_utc
|
||||
).iterator():
|
||||
when = as_aware(action_dt) or as_aware(finish_dt)
|
||||
if when is None:
|
||||
continue
|
||||
current = result.get(customer_id)
|
||||
if current is None or when < current:
|
||||
result[customer_id] = when
|
||||
return result
|
||||
|
||||
|
||||
def scheduled_visits_map(*, now=None):
|
||||
"""قرارهای «بازدید برنامهریزیشده»: ``bazdid + waite`` با تاریخ آینده."""
|
||||
return _action_appointments(
|
||||
Action.ActionType.BAZDID, (Action.Status.WAITE,), now=now,
|
||||
)
|
||||
|
||||
|
||||
def coordination_holds_map(*, now=None):
|
||||
"""قرارهای «هماهنگی» با تاریخ آینده.
|
||||
|
||||
هر دو وضعیت ``waite`` و ``finish`` در نظر گرفته میشوند (سازگاری با رفتار
|
||||
قبلی). حالت ``waite`` یعنی کارشناس هنوز تیک «انجام شد» نزده، و ``finish``
|
||||
یعنی تماس انجام شده و قرار برای آینده گذاشته شده است.
|
||||
"""
|
||||
return _action_appointments(
|
||||
Action.ActionType.HAMAHANGI,
|
||||
(Action.Status.WAITE, Action.Status.FINISH),
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def manual_holds_map(*, now=None):
|
||||
"""قرارهای دستی (ستون ``visit_hold_until``) — **با اعتبارسنجی**.
|
||||
|
||||
قاعدهٔ قطعی: ستون بهتنهایی معتبر نیست. فقط وقتی اثر دارد که یک اقدام
|
||||
«هماهنگی» با وضعیت ``waite`` یا ``finish`` و **تاریخ آینده** برای همان
|
||||
مشتری وجود داشته باشد. بنابراین:
|
||||
|
||||
* حذف هماهنگی ⇒ قرار خودکار بیاثر میشود،
|
||||
* لغو هماهنگی (``cancell``) ⇒ بیاثر میشود،
|
||||
* هماهنگیِ گذشته ⇒ بیاثر میشود،
|
||||
* کهنهماندن ستون هیچ اثر مخربی ندارد.
|
||||
|
||||
مرجع هماهنگی: هر ``hamahangi`` فعالی با تاریخ آینده (نه لزوماً برابر با ستون)،
|
||||
چون کاربر میتواند هماهنگی را ویرایش کند و تاریخ دیگری بگذارد.
|
||||
"""
|
||||
now = _now(now)
|
||||
rows = (
|
||||
Customer.objects.filter(visit_hold_until__gt=now)
|
||||
.values_list("id", "visit_hold_until")
|
||||
.iterator()
|
||||
)
|
||||
holds = {customer_id: as_aware(until) for customer_id, until in rows}
|
||||
if not holds:
|
||||
return {}
|
||||
|
||||
# مرجع اعتبار: وجود یک اقدام «هماهنگیِ فعال» برای همان مشتری (نه لزوماً با
|
||||
# تاریخ آینده). این نکته حیاتی است: سناریوی رایج «همین حالا تماس گرفتم و قرار
|
||||
# دو هفتهٔ بعد گذاشتم» تاریخ اقدام را «امروز» میذارد؛ اگر شرط آینده بودن تاریخ
|
||||
# اقدام را بگذاریم، همین مورد (که هدف اصلی این قابلیت است) بیاثر میشد.
|
||||
# پس فقط وضعیت مهم است: ``waite`` (در انتظار) یا ``finish`` (انجام شد).
|
||||
# ``cancell`` حساب نمیشود و حذف رکورد هم خودکار بیاثر میکند.
|
||||
active_coordination = set(
|
||||
Action.objects.filter(
|
||||
actionType=Action.ActionType.HAMAHANGI,
|
||||
status__in=(Action.Status.WAITE, Action.Status.FINISH),
|
||||
).values_list("customer_id", flat=True)
|
||||
)
|
||||
return {
|
||||
customer_id: until
|
||||
for customer_id, until in holds.items()
|
||||
if customer_id in active_coordination and until is not None
|
||||
}
|
||||
|
||||
|
||||
def appointment_sources_map(*, now=None):
|
||||
"""نگاشت ``customer_id → {منبع: تاریخ}`` برای هر سه منبع (فقط آینده).
|
||||
|
||||
برای صفحهٔ انتظار و آمار استفاده میشود؛ تصمیم «خارج از فهرست» را
|
||||
:func:`next_appointment_map` میگیرد که اجتماع همینهاست.
|
||||
"""
|
||||
now = _now(now)
|
||||
merged = {}
|
||||
for source, values in (
|
||||
(SOURCE_MANUAL, manual_holds_map(now=now)),
|
||||
(SOURCE_SCHEDULED, scheduled_visits_map(now=now)),
|
||||
(SOURCE_COORDINATION, coordination_holds_map(now=now)),
|
||||
):
|
||||
for customer_id, when in values.items():
|
||||
merged.setdefault(customer_id, {})[source] = when
|
||||
return merged
|
||||
|
||||
|
||||
# نام قدیمی برای سازگاری با فراخوانیهای موجود.
|
||||
def active_deferrals(*, now=None):
|
||||
"""نام قدیمیِ :func:`next_appointment_map` (سازگاری عقبرو)."""
|
||||
@@ -366,6 +460,100 @@ def active_deferral(customer, *, now=None):
|
||||
return next_appointment(customer, now=now)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# نوشتن «قرار دستی» روی مشتری
|
||||
# ---------------------------------------------------------------------------
|
||||
HOLD_DEFAULT_DAYS = 14
|
||||
HOLD_MAX_DAYS = 183 # ≈ ۶ ماه
|
||||
|
||||
|
||||
def end_of_local_day(moment):
|
||||
"""پایان همان روز به وقت محلی (بهصورت aware).
|
||||
|
||||
تصمیم محصولی: «قرار تا ۱ آذر» یعنی تا **آخر** ۱ آذر، نه تا ساعت ثبت.
|
||||
اگر صرفاً ``actionDateTime`` را ذخیره کنیم، قرارِ «امروز» بلافاصله منقضی
|
||||
میشد و مشتری همان لحظه به فهرست برمیگشت.
|
||||
"""
|
||||
moment = as_aware(moment)
|
||||
if moment is None:
|
||||
return None
|
||||
local = moment.astimezone(timezone.get_current_timezone())
|
||||
end_of_day = datetime.combine(local.date(), time(23, 59, 59))
|
||||
try:
|
||||
return timezone.make_aware(end_of_day, timezone.get_current_timezone())
|
||||
except Exception:
|
||||
return timezone.make_aware(
|
||||
end_of_day, timezone.get_current_timezone(), is_dst=False,
|
||||
)
|
||||
|
||||
|
||||
def default_hold_until(*, now=None, days=HOLD_DEFAULT_DAYS):
|
||||
"""پیشفرض «قرار بعدی»: پایان روزِ ``days`` روز بعد."""
|
||||
base = _now(now) + relativedelta.relativedelta(days=days)
|
||||
return end_of_local_day(base)
|
||||
|
||||
|
||||
def set_visit_hold(customer, until, *, now=None):
|
||||
"""ثبت قرار دستی؛ فقط اگر **جلوتر** از مقدار فعلی باشد مینویسد.
|
||||
|
||||
قاعدهٔ «فقط رو به جلو» (مثل ``refresh_last_bazdid``) جلوی این را میگیرد که
|
||||
ثبت هماهنگی توسط کارشناس دوم، قرارِ دورترِ قبلی را عقب بکشد.
|
||||
خروجی: ``True`` اگر نوشته شد.
|
||||
"""
|
||||
if customer is None or customer.pk is None:
|
||||
return False
|
||||
target = end_of_local_day(until)
|
||||
if target is None:
|
||||
return False
|
||||
current = as_aware(customer.visit_hold_until)
|
||||
if current is not None and current >= target:
|
||||
return False
|
||||
Customer.objects.filter(pk=customer.pk).update(
|
||||
visit_hold_until=target, visit_hold_set_at=_now(now),
|
||||
)
|
||||
customer.visit_hold_until = target
|
||||
return True
|
||||
|
||||
|
||||
def clear_visit_hold(customer):
|
||||
"""پاککردن قرار دستی (مثلاً وقتی بازدید واقعی انجام شد)."""
|
||||
if customer is None or customer.pk is None:
|
||||
return False
|
||||
if customer.visit_hold_until is None and customer.visit_hold_set_at is None:
|
||||
return False
|
||||
Customer.objects.filter(pk=customer.pk).update(
|
||||
visit_hold_until=None, visit_hold_set_at=None,
|
||||
)
|
||||
customer.visit_hold_until = None
|
||||
customer.visit_hold_set_at = None
|
||||
return True
|
||||
|
||||
|
||||
def normalize_hold_until(raw, *, now=None):
|
||||
"""اعتبارسنجی و نرمالسازی «قرار بعدی» از ورودی فرم.
|
||||
|
||||
قواعد:
|
||||
* مقدار خالی ⇒ ``None`` (بدون تعویق).
|
||||
* تاریخ باید در آینده یا امروز باشد (پایان روز پذیرفته است).
|
||||
* حداکثر ``HOLD_MAX_DAYS`` روز جلوتر (≈ ۶ ماه) — جلوگیری از «تعویق بیپایان».
|
||||
|
||||
خطای ``ValidationError`` با پیام فارسی برمیگرداند تا در ویو نشان داده شود.
|
||||
"""
|
||||
moment = as_aware(raw)
|
||||
if moment is None:
|
||||
return None
|
||||
target = end_of_local_day(moment)
|
||||
today_end = end_of_local_day(_now(now))
|
||||
if target < today_end:
|
||||
raise ValidationError("تاریخ قرار نمیتواند پیش از امروز باشد.")
|
||||
limit = today_end + relativedelta.relativedelta(days=HOLD_MAX_DAYS)
|
||||
if target > limit:
|
||||
raise ValidationError(
|
||||
f"قرار بعدی حداکثر میتواند {HOLD_MAX_DAYS} روز (۶ ماه) جلوتر باشد."
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# دورهٔ بازدید (منبع واحد)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -399,6 +587,9 @@ def visit_due_date(anchor, *, months=None):
|
||||
"""تاریخ موعد (تقویمی) برای یک لنگر: ``anchor + N ماه``.
|
||||
|
||||
تنها مرجع محاسبهٔ موعد، هم در صفحهٔ «انتظار» و هم در اسکنر پیامک.
|
||||
|
||||
توجه: ``months`` را در حلقهها **حتماً** پاس بدهید؛ وگرنه برای هر لنگر یک
|
||||
کوئری خواندنِ قالب پیامک زده میشود (باگ N+1).
|
||||
"""
|
||||
anchor = as_aware(anchor)
|
||||
if anchor is None:
|
||||
|
||||
Reference in new issue
Block a user