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

@@ -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: