improve bazdid 3

This commit is contained in:
javad committed 2026-10-21 12:24:52 +03:30
1 parent f8d768a724
commit f86d060e4d
6 files changed
+337 -121

No files matched your search

@@ -62,7 +62,9 @@
{% endif %} {% endif %}
{% for action in actions %} {% for action in actions %}
<div customer_id='{{ action.customer.id }}' action_id='{{ action.id }}' class="columns is-mobile is-multiline is-gapless actionBox {{ bgBox }}"> <div customer_id='{{ action.customer.id }}' action_id='{{ action.id }}'
customer_hold='{% if action.customer.visit_hold_until %}{{ action.customer.visit_hold_until|date:"c" }}{% endif %}'
class="columns is-mobile is-multiline is-gapless actionBox {{ bgBox }}">
<div class="column is-12-mobile is-12-desktop actionBoxDesc actionBoxOpenB"> <div class="column is-12-mobile is-12-desktop actionBoxDesc actionBoxOpenB">
<div class="pr-4 pl-4 pb-4 pt-4"> <div class="pr-4 pl-4 pb-4 pt-4">
{% ifequal action.status 'finish' %} {% ifequal action.status 'finish' %}
@@ -113,7 +115,9 @@
<p style="text-align: center; margin-bottom: 20px; color: #d90000; font-size: 18px;"> کارهای مانده از روزهای قبل</p> <p style="text-align: center; margin-bottom: 20px; color: #d90000; font-size: 18px;"> کارهای مانده از روزهای قبل</p>
{% for action in actionPr %} {% for action in actionPr %}
<div customer_id='{{ action.customer.id }}' action_id='{{ action.id }}' class="columns is-mobile is-multiline is-gapless actionBox {{ bgBox }}"> <div customer_id='{{ action.customer.id }}' action_id='{{ action.id }}'
customer_hold='{% if action.customer.visit_hold_until %}{{ action.customer.visit_hold_until|date:"c" }}{% endif %}'
class="columns is-mobile is-multiline is-gapless actionBox {{ bgBox }}">
<div class="column is-12-mobile is-12-desktop actionBoxDesc actionBoxOpenB"> <div class="column is-12-mobile is-12-desktop actionBoxDesc actionBoxOpenB">
<div class="pr-4 pl-4 pb-4 pt-4"> <div class="pr-4 pl-4 pb-4 pt-4">
{% ifequal action.status 'finish' %} {% ifequal action.status 'finish' %}
@@ -474,6 +478,26 @@
// مقدار undefined می‌شود. بدون این محافظ، یک ناهم‌خوانی نام کلاس باعث // مقدار undefined می‌شود. بدون این محافظ، یک ناهم‌خوانی نام کلاس باعث
// TypeError می‌شد و بقیهٔ منطقِ هندلر (پنهان‌کردن فیلدها، برداشتن تیک خودکار) // TypeError می‌شد و بقیهٔ منطقِ هندلر (پنهان‌کردن فیلدها، برداشتن تیک خودکار)
// اجرا نمی‌شد. // اجرا نمی‌شد.
// پرکردن پیکر «تا تاریخ پیگیری نشود» از مقدار ذخیره‌شدهٔ مشتری.
// بدون این، هنگام بازکردن مجدد یک اقدام، فیلد همیشه خالی دیده می‌شد
// (مقدار در دیتابیس بود ولی مودال آن را بارگذاری نمی‌کرد).
function seedHoldPicker(isoValue) {
var box = $("#visitHoldUntil");
if (!isoValue) {
resetHoldPicker();
return;
}
var ms = new Date(isoValue).getTime();
if (isNaN(ms)) {
resetHoldPicker();
return;
}
if (dateTimePickerHoldUntil && typeof dateTimePickerHoldUntil.setDate === 'function') {
dateTimePickerHoldUntil.setDate(ms);
}
box.val(String(ms));
}
function resetHoldPicker() { function resetHoldPicker() {
if (dateTimePickerHoldUntil && typeof dateTimePickerHoldUntil.setDate === 'function') { if (dateTimePickerHoldUntil && typeof dateTimePickerHoldUntil.setDate === 'function') {
dateTimePickerHoldUntil.setDate(); dateTimePickerHoldUntil.setDate();
@@ -576,8 +600,10 @@
isEditMode = true; isEditMode = true;
$("#sabtBtn05").val("ویرایش"); $("#sabtBtn05").val("ویرایش");
var actionEditUrl = "{% url 'admin_panel:customerEditAction' 1111 2222 %}"; var actionEditUrl = "{% url 'admin_panel:customerEditAction' 1111 2222 %}";
var action_id = $(this).parent().attr("action_id"); var row = $(this).parent();
var customer_id = $(this).parent().attr("customer_id"); var action_id = row.attr("action_id");
var customer_id = row.attr("customer_id");
var customerHold = row.attr("customer_hold") || "";
actionEditUrl = actionEditUrl.replace('1111', customer_id); actionEditUrl = actionEditUrl.replace('1111', customer_id);
actionEditUrl = actionEditUrl.replace('2222', action_id); actionEditUrl = actionEditUrl.replace('2222', action_id);
$("#ActionBox").prop("action", actionEditUrl); $("#ActionBox").prop("action", actionEditUrl);
@@ -602,6 +628,9 @@
// فیلد «تا تاریخ پیگیری نشود» با نوع اقدام هم‌راستا شود (در حالت ویرایش هم). // فیلد «تا تاریخ پیگیری نشود» با نوع اقدام هم‌راستا شود (در حالت ویرایش هم).
closeHoldHelp(); closeHoldHelp();
toggleCoordinationField(ActionParseData[0]['fields']['actionType']); toggleCoordinationField(ActionParseData[0]['fields']['actionType']);
// مقدار ذخیره‌شدهٔ «قرار بعدی» را در همان فیلد نشان بده (بعد از toggle،
// چون toggle برای انواع غیرهماهنگی فیلد را پاک می‌کند).
seedHoldPicker(customerHold);
$("#desc").val(ActionParseData[0]['fields']['desc']); $("#desc").val(ActionParseData[0]['fields']['desc']);
$("#userID").val(ActionParseData[0]['fields']['forUser']); $("#userID").val(ActionParseData[0]['fields']['forUser']);
if(ActionParseData[0]['fields']['status'] == 'finish'){ if(ActionParseData[0]['fields']['status'] == 'finish'){
+35 -20
View File
@@ -24,7 +24,7 @@
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.ez-stats { grid-auto-flow: row; grid-template-columns: repeat(5, 1fr); overflow: visible; } .ez-stats { grid-auto-flow: row; grid-template-columns: repeat(6, 1fr); overflow: visible; }
} }
.ez-stat { .ez-stat {
display: flex; flex-direction: column; gap: .18rem; display: flex; flex-direction: column; gap: .18rem;
@@ -40,6 +40,7 @@
.ez-stat.success { border-top-color: #16a34a; } .ez-stat.success { border-top-color: #16a34a; }
.ez-stat.warning { border-top-color: #d97706; } .ez-stat.warning { border-top-color: #d97706; }
.ez-stat.link { border-top-color: #0284c7; } .ez-stat.link { border-top-color: #0284c7; }
.ez-stat.violet { border-top-color: #7c3aed; }
.ez-stat-top { .ez-stat-top {
display: flex; align-items: baseline; justify-content: space-between; gap: .4rem; display: flex; align-items: baseline; justify-content: space-between; gap: .4rem;
@@ -128,6 +129,7 @@
.ez-badge.success { background: #dcfce7; color: #15803d; } .ez-badge.success { background: #dcfce7; color: #15803d; }
.ez-badge.warning { background: #fef3c7; color: #b45309; } .ez-badge.warning { background: #fef3c7; color: #b45309; }
.ez-badge.mixed { background: #e0f2fe; color: #0369a1; } .ez-badge.mixed { background: #e0f2fe; color: #0369a1; }
.ez-badge.coordination { background: #ede9fe; color: #6d28d9; }
/* تأخیر به‌صورت چیپ (خوانا در یک نگاه) */ /* تأخیر به‌صورت چیپ (خوانا در یک نگاه) */
.ez-late { .ez-late {
@@ -202,10 +204,8 @@
<b>بیش از {{ counts.period_months }} ماه</b> گذشته باشد. <b>بیش از {{ counts.period_months }} ماه</b> گذشته باشد.
روی هر کارت بزنید تا فقط همان گروه نمایش داده شود؛ روی هر کارت بزنید تا فقط همان گروه نمایش داده شود؛
<b>کل = بدون قرار + با قرار</b>. <b>کل = بدون قرار + با قرار</b>.
{% if counts.both %} سه کارتِ آخر سه نوع قرارِ جدا هستند؛ یک مشتری می‌تواند بیش از
({{ counts.both }} مشتری هم قرار هماهنگی دارند و هم بازدید یکی داشته باشد، پس جمع آن‌ها ممکن است از «با قرار» بیشتر شود.
برنامه‌ریزی‌شده، پس در هر دو کارت شمرده می‌شوند.)
{% endif %}
</p> </p>
</div> </div>
@@ -259,6 +259,16 @@
</span> </span>
<span class="ez-desc">اقدام «بازدید» با تاریخ آینده ثبت شده است</span> <span class="ez-desc">اقدام «بازدید» با تاریخ آینده ثبت شده است</span>
</a> </a>
<a href="?view=coordination&sort={{ sort }}{% if searchVal %}&searchVal={{ searchVal }}&searchOn={{ searchOn }}{% endif %}"
class="ez-stat violet {% if view == 'coordination' %}is-active{% endif %}"
title="اقدام «هماهنگی» با تاریخ پیگیری در آینده">
<span class="ez-stat-top">
<span class="ez-lbl">هماهنگی</span>
<span class="ez-num" style="color:#7c3aed">{{ counts.coordination }}</span>
</span>
<span class="ez-desc">تماس گرفته شده و پیگیری به آینده موکول شده است</span>
</a>
</div> </div>
{# تفکیک مدت تأخیر در نمای جاری #} {# تفکیک مدت تأخیر در نمای جاری #}
@@ -293,21 +303,23 @@
<div class="table-container"> <div class="table-container">
<table class="table ez-table"> <table class="table ez-table">
<colgroup> <colgroup>
<col style="width: 23%"> <col style="width: 21%">
<col style="width: 13%"> <col style="width: 12%">
<col style="width: 10%">
<col style="width: 10%"> <col style="width: 10%">
<col style="width: 11%">
<col style="width: 10%"> <col style="width: 10%">
<col style="width: 9%"> <col style="width: 9%">
<col style="width: 8%">
<col style="width: 9%">
<col style="width: 11%"> <col style="width: 11%">
<col style="width: 13%">
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th>نام و نام خانوادگی</th> <th>نام و نام خانوادگی</th>
<th>وضعیت قرار</th> <th>وضعیت قرار</th>
<th>قرار هماهنگی</th> <th>قرار دستی</th>
<th>بازدید برنامه‌ریزی‌شده</th> <th>بازدید برنامه‌ریزی‌شده</th>
<th>هماهنگی</th>
<th>آخرین بازدید</th> <th>آخرین بازدید</th>
<th>تأخیر</th> <th>تأخیر</th>
<th>تلفن</th> <th>تلفن</th>
@@ -328,23 +340,26 @@
</td> </td>
{% with appt=appointment_rows|row_for:customer.id %} {% with appt=appointment_rows|row_for:customer.id %}
<td data-label="وضعیت" class="ez-nolabel"> <td data-label="وضعیت" class="ez-nolabel">
{% if appt.manual and appt.scheduled or appt.manual and appt.coordination %} {% if appt.source_count > 1 %}
<span class="ez-badge mixed">قرار + بازدید</span> <span class="ez-badge mixed">{{ appt.source_count }} قرار</span>
{% elif appt.manual %} {% elif appt.manual %}
<span class="ez-badge warning">قرار هماهنگی</span> <span class="ez-badge warning">قرار دستی</span>
{% elif appt.scheduled or appt.coordination %} {% elif appt.scheduled %}
<span class="ez-badge success">بازدید برنامه‌ریزی‌شده</span> <span class="ez-badge success">بازدید برنامه</span>
{% elif appt.coordination %}
<span class="ez-badge coordination">هماهنگی</span>
{% else %} {% else %}
<span class="ez-badge danger">بدون قرار</span> <span class="ez-badge danger">بدون قرار</span>
{% endif %} {% endif %}
</td> </td>
<td data-label="قرار هماهنگی" class="ez-num ez-nowrap"> <td data-label="قرار دستی" class="ez-num ez-nowrap">
{% if appt.manual %}{{ appt.manual|toJalali }}{% else %}<span class="ez-muted">—</span>{% endif %} {% if appt.manual %}{{ appt.manual|toJalali }}{% else %}<span class="ez-muted">—</span>{% endif %}
</td> </td>
<td data-label="بازدید برنامه" class="ez-num ez-nowrap"> <td data-label="بازدید برنامه" class="ez-num ez-nowrap">
{% if appt.scheduled %}{{ appt.scheduled|toJalali }} {% if appt.scheduled %}{{ appt.scheduled|toJalali }}{% else %}<span class="ez-muted">—</span>{% endif %}
{% elif appt.coordination %}{{ appt.coordination|toJalali }}<div class="ez-sub">در انتظار تماس</div> </td>
{% else %}<span class="ez-muted">—</span>{% endif %} <td data-label="هماهنگی" class="ez-num ez-nowrap">
{% if appt.coordination %}{{ appt.coordination|toJalali }}{% else %}<span class="ez-muted">—</span>{% endif %}
</td> </td>
<td data-label="آخرین بازدید" class="ez-num ez-nowrap lastView"> <td data-label="آخرین بازدید" class="ez-num ez-nowrap lastView">
{% if appt.last_visit %}{{ appt.last_visit|toJalali }}{% else %}<span class="ez-muted">بدون بازدید</span>{% endif %} {% if appt.last_visit %}{{ appt.last_visit|toJalali }}{% else %}<span class="ez-muted">بدون بازدید</span>{% endif %}
@@ -382,7 +397,7 @@
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="8" class="has-text-centered py-5"> <td colspan="9" class="has-text-centered py-5">
<span class="has-text-grey">موردی در این نما پیدا نشد.</span> <span class="has-text-grey">موردی در این نما پیدا نشد.</span>
</td> </td>
</tr> </tr>
+19 -13
View File
@@ -91,21 +91,26 @@ def _action_payload_from_request(request):
} }
def _apply_visit_hold(customer_obj, payload, *, status): def _apply_visit_hold(customer_obj, payload, *, status, is_new):
"""هم‌راستاکردن «قرار بعدی» با اقدام ثبت/ویرایش‌شده. """هم‌راستاکردن «قرار بعدی» با اقدام ثبت/ویرایش‌شده.
قواعد (یکسان برای ایجاد و ویرایش — قبلاً ایجاد این را انجام نمی‌داد): قواعد:
* فقط برای اقدام نوع «هماهنگی» معنا دارد؛ * برای اقدام نوع «هماهنگی»:
- اگر تاریخ قرار داده شده باشد ⇒ ``set_visit_hold`` (فقط رو به جلو) - تاریخ قرار داده شده ⇒ ``set_visit_hold`` (فقط رو به جلو)
- اگر «انجام شد» تیک خورده و تاریخ خالی است ⇒ پیش‌فرض ۱۴ روز - «انجام شد» تیک خورده و تاریخ خالی ⇒ پیش‌فرض ۱۴ روز
- اگر هماهنگی لغو شده باشد ⇒ قرار پاک می‌شود - لغو شده ⇒ (در ایجاد) پاک می‌شود
* **هر اقدام دیگری** (از جمله «بازدید») ⇒ قرار دستی پاک می‌شود؛ چون هدفِ * برای **هر اقدام دیگری** (از جمله «بازدید»):
قرار (پیگیری در آینده) با انجام/برنامه‌ریزی بازدید منتفی است. - در حالت **ایجاد**، این اقدام آخرین فعالیت مشتری است ⇒ قرار دستی پاک
می‌شود (سناریوی گزارش‌شده: ثبت بازدید پس از هماهنگی).
- در حالت **ویرایش**، رکوردی تاریخی است و ممکن است قرارِ فعلی از یک
هماهنگیِ *جدیدتر* آمده باشد؛ پس دست نمی‌زنیم. اعتبارسنجی
:func:`manual_holds_map` (شرط «آخرین فعالیت = هماهنگی») خودش تصمیم
می‌گیرد. بدون این تفکیک، ویرایش یک اقدام قدیمی قرار معتبر را پاک می‌کرد.
""" """
is_coordination = payload['actionType'] == Action.ActionType.HAMAHANGI is_coordination = payload['actionType'] == Action.ActionType.HAMAHANGI
if not is_coordination or status == Action.Status.CANCELL: if not is_coordination or status == Action.Status.CANCELL:
return clear_visit_hold(customer_obj) return clear_visit_hold(customer_obj) if is_new else True
raw = payload.get('holdRaw') raw = payload.get('holdRaw')
if raw is not None: if raw is not None:
@@ -152,7 +157,7 @@ def customerNewAction(request ,c_id):
# last_bazdid_dt فقط از تابع واحد بازمحاسبه می‌شود؛ نه از finishDateTime. # last_bazdid_dt فقط از تابع واحد بازمحاسبه می‌شود؛ نه از finishDateTime.
refresh_last_bazdid(customer_obj, reason=REASON_CREATE, user=request.user) refresh_last_bazdid(customer_obj, reason=REASON_CREATE, user=request.user)
# «قرار بعدی» فقط برای هماهنگی. # «قرار بعدی» فقط برای هماهنگی.
_apply_visit_hold(customer_obj, payload, status=newAction.status) _apply_visit_hold(customer_obj, payload, status=newAction.status, is_new=True)
# پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید. # پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
if newAction.actionType == Action.ActionType.BAZDID: if newAction.actionType == Action.ActionType.BAZDID:
@@ -210,9 +215,10 @@ def customerEditAction(request ,c_id, action_id):
# (تا مقداری که این رکورد ساخته بود با منبع حقیقت هم‌راستا شود). # (تا مقداری که این رکورد ساخته بود با منبع حقیقت هم‌راستا شود).
reason = REASON_CANCEL if (was_done and not payload['is_done']) else REASON_EDIT reason = REASON_CANCEL if (was_done and not payload['is_done']) else REASON_EDIT
refresh_last_bazdid(customer_obj, reason=reason, user=request.user) refresh_last_bazdid(customer_obj, reason=reason, user=request.user)
# «قرار بعدی»: با ویرایش هم‌راستا می‌شود؛ اگر اقدام دیگر هماهنگی نباشد یا # «قرار بعدی»: با ویرایش هم‌راستا می‌شود. برای اقدام غیرهماهنگی عمداً
# هماهنگی لغو شود، قرار دستی پاک می‌شود. # پاک نمی‌کنیم؛ رکورد ویرایش‌شده ممکن است قدیمی‌تر از یک هماهنگیِ جدیدتر
_apply_visit_hold(customer_obj, payload, status=newAction.status) # باشد و پاک‌کردن، قرار معتبر را از بین ببرد.
_apply_visit_hold(customer_obj, payload, status=newAction.status, is_new=False)
#return redirect('/panel/customer/'+str(c_id)+'/action') #return redirect('/panel/customer/'+str(c_id)+'/action')
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found')) return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
+47 -47
View File
@@ -24,7 +24,8 @@ from ..visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED
# نماهای صفحهٔ «انتظار» (فیلتر ?view=). پیش‌فرض = فقط «بدون قرار» تا رفتار # نماهای صفحهٔ «انتظار» (فیلتر ?view=). پیش‌فرض = فقط «بدون قرار» تا رفتار
# قبلی صفحه برای کاربران حفظ شود. # قبلی صفحه برای کاربران حفظ شود.
ENTEZAR_VIEWS = ('uncoordinated', 'manual', 'scheduled', 'coordinated', 'all') ENTEZAR_VIEWS = ('uncoordinated', 'manual', 'scheduled', 'coordination',
'coordinated', 'all')
ENTEZAR_DEFAULT_VIEW = 'uncoordinated' ENTEZAR_DEFAULT_VIEW = 'uncoordinated'
# حالت‌های مرتب‌سازی (?sort=): # حالت‌های مرتب‌سازی (?sort=):
# oldest ⇒ قدیمی‌ترین بازدید اول (پیش‌فرض/اولویت کارشناس) # oldest ⇒ قدیمی‌ترین بازدید اول (پیش‌فرض/اولویت کارشناس)
@@ -341,18 +342,17 @@ def customerEdit(request, c_id):
@login_required(login_url='/panel/p_login/') @login_required(login_url='/panel/p_login/')
def entezar(request): def entezar(request):
"""فهرست «منتظر بازدید» + تفکیک «با قرار» و «بدون قرار». """فهرست «منتظر بازدید» + تفکیک بر اساس **نوع قرار**.
منبع حقیقت تاریخ بازدید ``visit_policy.last_visit_map`` است و منبع «قرار بعدی» منبع حقیقت تاریخ بازدید ``visit_policy.last_visit_map`` است و منبع قرارها
``appointment_sources_map`` (سه منبع: قرار دستی، بازدید برنامه‌ریزی‌شده، ``appointment_sources_map`` که سه منبع مستقل دارد:
هماهنگیِ در انتظار). هر مشتری دقیقاً در یکی از چهار گروه زیر است:
* ``A`` — معوق بدون هیچ قرار (پیش‌فرض نمایش؛ رفتار قبلی صفحه) * ``manual`` — قرار دستی روی مشتری (تعویق از فرم هماهنگی)
* ``B`` — معوق با قرار دستی (تعویق از فرم هماهنگی) * ``scheduled`` — اقدام «بازدید» با تاریخ آینده
* ``C`` — معوق با بازدید برنامه‌ریزی‌شده یا هماهنگی در انتظار * ``coordination`` — اقدام «هماهنگی» با تاریخ پیگیری در آینده
* ``D`` — معوق با بیش از یک منبعِ قرار
فیلتر با ``?view=`` و قابل ترکیب با جست‌وجوی آدرس است. هر فیلتر فقط منبع خودش را نشان می‌دهد (یک مشتری می‌تواند چند منبع داشته
باشد و در چند فیلتر دیده شود). فیلتر با ``?view=`` و قابل ترکیب با جست‌وجو.
""" """
searchVal = request.GET.get('searchVal', '') searchVal = request.GET.get('searchVal', '')
searchOn = request.GET.get('searchOn', '') searchOn = request.GET.get('searchOn', '')
@@ -403,29 +403,35 @@ def entezar(request):
if visits.get(customer_id) is None or visits[customer_id] <= due_cut if visits.get(customer_id) is None or visits[customer_id] <= due_cut
] ]
# تفکیک چهارگروهی (MECE): هر مشتری فقط در یک گروه. # تفکیک بر اساس **منبعِ** قرار: هر مشتری می‌تواند صفر تا سه منبع داشته باشد
groups = {'A': [], 'B': [], 'C': [], 'D': []} # (قرار دستی، بازدید برنامه‌ریزی‌شده، هماهنگی). قبلاً «هماهنگی» با «بازدید»
# در یک گروه جمع می‌شد و در نمای «بازدید برنامه‌ریزی‌شده» دیده می‌شد؛ حالا
# هر منبع گروه نمایشیِ خودش را دارد.
with_manual, with_scheduled, with_coordination = set(), set(), set()
for customer_id in stale_ids: for customer_id in stale_ids:
customer_sources = sources.get(customer_id) or {} customer_sources = sources.get(customer_id) or {}
manual = SOURCE_MANUAL in customer_sources if SOURCE_MANUAL in customer_sources:
scheduled = (SOURCE_SCHEDULED in customer_sources with_manual.add(customer_id)
or SOURCE_COORDINATION in customer_sources) if SOURCE_SCHEDULED in customer_sources:
if manual and scheduled: with_scheduled.add(customer_id)
groups['D'].append(customer_id) if SOURCE_COORDINATION in customer_sources:
elif manual: with_coordination.add(customer_id)
groups['B'].append(customer_id)
elif scheduled:
groups['C'].append(customer_id)
else:
groups['A'].append(customer_id)
selected = { coordinated_ids = [cid for cid in stale_ids if cid in (
'uncoordinated': groups['A'], with_manual | with_scheduled | with_coordination)]
'manual': groups['B'] + groups['D'], selected_map = {
'scheduled': groups['C'] + groups['D'], # نداشتن هیچ قرار
'coordinated': groups['B'] + groups['C'] + groups['D'], 'uncoordinated': [cid for cid in stale_ids if cid not in (
with_manual | with_scheduled | with_coordination)],
# هر مشتری که این منبع را دارد (حتی اگر منبع دیگری هم داشته باشد)
'manual': [cid for cid in stale_ids if cid in with_manual],
'scheduled': [cid for cid in stale_ids if cid in with_scheduled],
'coordination': [cid for cid in stale_ids if cid in with_coordination],
# اجتماع هر سه منبع
'coordinated': coordinated_ids,
'all': stale_ids, 'all': stale_ids,
}[view] }
selected = selected_map[view]
# «مدت تأخیر» = از چه زمانی بازدید عقب افتاده است (به روز). # «مدت تأخیر» = از چه زمانی بازدید عقب افتاده است (به روز).
# مبنای محاسبه «موعد بازدید» است، نه خود بازدید: موعد = بازدید + دوره. # مبنای محاسبه «موعد بازدید» است، نه خود بازدید: موعد = بازدید + دوره.
@@ -491,17 +497,14 @@ def entezar(request):
counts = { counts = {
'total': len(stale_ids), 'total': len(stale_ids),
'uncoordinated': len(groups['A']), 'uncoordinated': len(selected_map['uncoordinated']),
# توجه: عددِ هر کارت باید **همان چیزی** باشد که با کلیک روی آن کارت # عددِ هر کارت دقیقاً همان چیزی است که با کلیک روی آن دیده می‌شود؛
# دیده می‌شود. فیلتر ``view=manual`` گروه‌های B و D را نشان می‌دهد و # چون یک مشتری می‌تواند چند منبع داشته باشد، جمع کارت‌ها ممکن است
# ``view=scheduled`` گروه‌های C و D را؛ پس اینجا هم همان اجتماع # از «با قرار» بیشتر شود. در راهنمای بالای صفحه توضیح داده می‌شود.
# شمرده می‌شود (قبلاً فقط B/C شمرده می‌شد و عدد با فهرست نمی‌خواند). 'manual': len(selected_map['manual']),
'manual': len(groups['B']) + len(groups['D']), 'scheduled': len(selected_map['scheduled']),
'scheduled': len(groups['C']) + len(groups['D']), 'coordination': len(selected_map['coordination']),
'manual_only': len(groups['B']), 'coordinated': len(selected_map['coordinated']),
'scheduled_only': len(groups['C']),
'both': len(groups['D']),
'coordinated': len(groups['B']) + len(groups['C']) + len(groups['D']),
'selected': len(selected), 'selected': len(selected),
'buckets': buckets, 'buckets': buckets,
# برای متن راهنمای بالای صفحه («بیش از N ماه از بازدید گذشته»). # برای متن راهنمای بالای صفحه («بیش از N ماه از بازدید گذشته»).
@@ -524,19 +527,16 @@ def entezar(request):
appointment_rows = {} appointment_rows = {}
for customer in customerHa.object_list: for customer in customerHa.object_list:
entry = sources.get(customer.id) or {} entry = sources.get(customer.id) or {}
flags = [key for key in (SOURCE_MANUAL, SOURCE_SCHEDULED,
SOURCE_COORDINATION) if key in entry]
appointment_rows[customer.id] = { appointment_rows[customer.id] = {
'manual': entry.get(SOURCE_MANUAL), 'manual': entry.get(SOURCE_MANUAL),
'scheduled': entry.get(SOURCE_SCHEDULED), 'scheduled': entry.get(SOURCE_SCHEDULED),
'coordination': entry.get(SOURCE_COORDINATION), 'coordination': entry.get(SOURCE_COORDINATION),
'overdue_days': overdue_days.get(customer.id, 0), 'overdue_days': overdue_days.get(customer.id, 0),
'last_visit': visits.get(customer.id), 'last_visit': visits.get(customer.id),
'group': ( # تعداد منابع (۰ تا ۳) — قالب بر اساس آن برچسب وضعیت را می‌سازد.
'D' if (SOURCE_MANUAL in entry and ( 'source_count': len(flags),
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', { return render(request, 'admin_panel/entezar.html', {
+63 -24
View File
@@ -104,7 +104,7 @@
| کنترل | تقویم شمسی (همان `pDatepicker` پروژه) | | کنترل | تقویم شمسی (همان `pDatepicker` پروژه) |
| پیش‌فرض | اگر خالی بماند و تیک «انجام شد» زده شود ⇒ **+۱۴ روز** | | پیش‌فرض | اگر خالی بماند و تیک «انجام شد» زده شود ⇒ **+۱۴ روز** |
| سقف | **۶ ماه** (اعتبارسنجی سروری) | | سقف | **۶ ماه** (اعتبارسنجی سروری) |
| حالت ویرایش | فیلد با نوع اقدام هم‌راستا می‌شود (نمایش/پنهان) | | حالت ویرایش | فیلد با نوع اقدام هم‌راستا می‌شود (نمایش/پنهان) **و مقدار ذخیره‌شدهٔ مشتری در آن بارگذاری می‌شود** |
نکتهٔ مهم: فیلد «تاریخ و ساعتی که کار باید انجام شود» (`dateTimeAnjam`) همچنان نکتهٔ مهم: فیلد «تاریخ و ساعتی که کار باید انجام شود» (`dateTimeAnjam`) همچنان
«زمان تماس» است و دست نخورده باقی می‌ماند. دو مفهوم، دو فیلد. «زمان تماس» است و دست نخورده باقی می‌ماند. دو مفهوم، دو فیلد.
@@ -177,6 +177,30 @@
اما کادر هشدار برای هماهنگیِ انجام‌شده به‌صورت اطلاعاتی نمایش داده می‌شود. اما کادر هشدار برای هماهنگیِ انجام‌شده به‌صورت اطلاعاتی نمایش داده می‌شود.
* **باز شدن مجدد مودال**: تیک، هشدار و وضعیت تأیید همیشه ریست می‌شوند. * **باز شدن مجدد مودال**: تیک، هشدار و وضعیت تأیید همیشه ریست می‌شوند.
#### بارگذاری مقدار ذخیره‌شده در حالت ویرایش
هر ردیف اقدام، مقدار فعلی `Customer.visit_hold_until` را به‌صورت ISO در
صفت `customer_hold` نگه می‌دارد. هنگام کلیک روی ردیف (باز کردن فرم ویرایش)،
تابع `seedHoldPicker()` آن را به تقویم شمسی و فیلد مخفی `visitHoldUntil`
می‌نویسد.
> **باگی که این بخش رفع می‌کند:** قبلاً فرم ویرایش مقدار را از دیتابیس
> نمی‌خواند. مقدار در سرور **درست ذخیره می‌شد** ولی چون فیلد هنگام باز شدن
> مجدد خالی نمایش داده می‌شد، کاربر فکر می‌کرد «ثبت نشده است».
ترتیب کار مهم است: `seedHoldPicker()` **بعد از** `toggleCoordinationField()`
صدا زده می‌شود، چون برای انواع غیرهماهنگی، `toggle` فیلد را پاک می‌کند.
#### ایمنی ویرایش رکوردهای قدیمی
در حالت **ویرایش**، تغییر نوع اقدام به غیرهماهنگی، قرار دستی را پاک
**نمی‌کند**؛ زیرا رکورد ویرایش‌شده ممکن است قدیمی‌تر از یک هماهنگیِ جدیدتر
باشد و پاک‌کردن، قرار معتبر را از بین می‌برد. اعتبارسنجی `manual_holds_map`
(شرط «آخرین فعالیت = هماهنگی») خودش قرار را بی‌اثر می‌کند.
در حالت **ایجاد** برعکس است: اقدام تازه آخرین فعالیت مشتری است، پس ثبت
بازدید جدید قرار قبلی را پاک می‌کند.
### ۴.۳ راهنمای بازشونده (فقط «هماهنگی») ### ۴.۳ راهنمای بازشونده (فقط «هماهنگی»)
زیر فیلد تاریخ پیگیری، یک دکمهٔ راهنما با آیکون `!` قرار دارد که پنل توضیحات را زیر فیلد تاریخ پیگیری، یک دکمهٔ راهنما با آیکون `!` قرار دارد که پنل توضیحات را
@@ -231,40 +255,51 @@ visit_hold_until = max(مقدار فعلی، تاریخ جدید)
## ۵. صفحهٔ انتظار: چهار گروه و فیلترها ## ۵. صفحهٔ انتظار: چهار گروه و فیلترها
هر مشتری معوق **دقیقاً در یکی** از این چهار گروه است: تفکیک بر اساس **نوعِ منبع** است، نه «چند گروه ثابت». هر مشتری می‌تواند صفر تا
سه منبع داشته باشد:
| گروه | تعریف | | منبع | تعریف | فیلتر |
|---|---| |---|---|---|
| **A** | معوق، بدون هیچ قرار | | `manual` | ستون `visit_hold_until` (تعویق از فرم هماهنگی) | `?view=manual` |
| **B** | معوق، فقط قرار دستی (`manual`) | | `scheduled` | اقدام `bazdid` + `waite` با تاریخ آینده | `?view=scheduled` |
| **C** | معوق، فقط بازدید برنامه‌ریزی‌شده یا هماهنگی در انتظار | | `coordination` | اقدام `hamahangi` (waite/finish) با تاریخ آینده | `?view=coordination` |
| **D** | معوق، هم قرار دستی و هم بازدید برنامه‌ریزی‌شده |
> **باگ قبلی:** `coordination` با `scheduled` در یک گروه جمع می‌شد؛ نتیجه اینکه
> مشتریانی که فقط «هماهنگی» داشتند در نمای «بازدید برنامه‌ریزی‌شده» دیده
> می‌شدند و برچسب «بازدید برنامه‌ریزی‌شده» می‌گرفتند. حالا هر منبع فیلتر و
> ستون خودش را دارد.
### فیلترها (قابل بوکمارک و ترکیب با جست‌وجو) ### فیلترها (قابل بوکمارک و ترکیب با جست‌وجو)
| URL | نمایش | شامل | | URL | نمایش | شامل |
|---|---|---| |---|---|---|
| `/panel/entezar/` (بدون پارامتر) | **پیش‌فرض = فقط A** (رفتار قبلی صفحه) | A | | `/panel/entezar/` (بدون پارامتر) | **پیش‌فرض = بدون قرار** (رفتار قبلی) | بدون هیچ منبع |
| `?view=uncoordinated` | بدون قرار | A | | `?view=uncoordinated` | بدون قرار | بدون هیچ منبع |
| `?view=manual` | قرار هماهنگی | B + D | | `?view=manual` | قرار دستی | هر مشتری با `manual` |
| `?view=scheduled` | بازدید برنامه‌ریزی‌شده | C + D | | `?view=scheduled` | بازدید برنامه‌ریزی‌شده | هر مشتری با `scheduled` |
| `?view=coordinated` | همهٔ دارای قرار | B + C + D | | `?view=coordination` | هماهنگی | هر مشتری با `coordination` |
| `?view=all` | همهٔ معوق‌ها | A + B + C + D | | `?view=coordinated` | همهٔ دارای قرار | اجتماع هر سه منبع |
| مقدار نامعتبر | برگشت به پیش‌فرض، **بدون خطا** | A | | `?view=all` | همهٔ معوق‌ها | همه |
| مقدار نامعتبر | برگشت به پیش‌فرض، **بدون خطا** | بدون هیچ منبع |
چون یک مشتری می‌تواند چند منبع داشته باشد، در چند فیلتر دیده می‌شود؛ اما در
`?view=coordinated` فقط **یک‌بار** شمرده می‌شود. به همین دلیل جمع سه کارت منبع
می‌تواند از «با قرار» بیشتر باشد (صفحه این را در راهنمای بالای خود توضیح می‌دهد).
### ستون‌های جدول ### ستون‌های جدول
| ستون | توضیح | | ستون | توضیح |
|---|---| |---|---|
| وضعیت قرار | برچسب «با قرار» / «بدون قرار» | | وضعیت قرار | «بدون قرار» / «قرار دستی» / «بازدید برنامه» / «هماهنگی» / «N قرار» |
| قرار هماهنگی | تاریخ شمسی قرار دستی (یا —) | | قرار دستی | تاریخ شمسی قرار دستی (یا —) |
| بازدید برنامه‌ریزی‌شده | تاریخ شمسی قرار `Action` (یا —) | | بازدید برنامه‌ریزی‌شده | تاریخ شمسی اقدام بازدید با تاریخ آینده (یا —) |
| هماهنگی | تاریخ شمسی اقدام هماهنگی با تاریخ آینده (یا —) |
| آخرین بازدید | بدون تغییر نسبت به قبل | | آخرین بازدید | بدون تغییر نسبت به قبل |
### شمارندهٔ داشبورد ### شمارندهٔ داشبورد
`CacheTable('9mahbazdid')` = **گروه A** (کارِ فوری = بدون قرار). هر بار باز شدن `CacheTable('9mahbazdid')` = **بدون قرار** (کارِ فوری). هر بار باز شدن صفحه
صفحه به‌روز می‌شود. به‌روز می‌شود. با تغییر تفکیک، این عدد **تغییر نمی‌کند**.
--- ---
@@ -461,8 +496,11 @@ python manage.py test tests
## ۹. آنچه تغییر نکرد ## ۹. آنچه تغییر نکرد
- منطق «چه کسی عقب‌افتاده است» (همان `last_visit_map` + `due_cut`). - منطق «چه کسی عقب‌افتاده است» (همان `last_visit_map` + `due_cut`).
- قاعدهٔ اعتبار قرار دستی (وجود هماهنگی فعال). - قاعدهٔ اعتبار قرار دستی (وجود هماهنگی فعال + آخرین فعالیت بودن).
- اسکنر پیامک، `recalc_last_bazdid` و چرخهٔ ۹ ماهه. - `visit_policy.py` به‌طور کامل: `next_appointment_map`، `scheduled_visits_map`،
`coordination_holds_map` — پس **اسکنر پیامک**، `recalc_last_bazdid` و چرخهٔ
۹ ماهه دقیقاً مثل قبل کار می‌کنند.
- شمارندهٔ داشبورد (`CacheTable('9mahbazdid')` = بدون قرار).
- لینک‌های قدیمی `?view=...` و رفتار پیش‌فرض (نمای «بدون قرار»). - لینک‌های قدیمی `?view=...` و رفتار پیش‌فرض (نمای «بدون قرار»).
## ۱۰. پارامترهای URL ## ۱۰. پارامترهای URL
@@ -470,9 +508,10 @@ python manage.py test tests
``` ```
/panel/entezar/ نمای پیش‌فرض (بدون قرار، قدیمی‌ترین) /panel/entezar/ نمای پیش‌فرض (بدون قرار، قدیمی‌ترین)
?view=all همهٔ معوق‌ها ?view=all همهٔ معوق‌ها
?view=coordinated با قرار ?view=coordinated با قرار (اجتماع سه منبع)
?view=manual فقط قرار هماهنگی ?view=manual فقط قرار دستی
?view=scheduled فقط بازدید برنامه‌ریزی‌شده ?view=scheduled فقط بازدید برنامه‌ریزی‌شده
?view=coordination فقط هماهنگی
?sort=overdue بیشترین تأخیر اول ?sort=overdue بیشترین تأخیر اول
?sort=newest جدیدترین بازدید اول ?sort=newest جدیدترین بازدید اول
?searchVal=رضایی&searchOn=name جست‌وجوی نام ?searchVal=رضایی&searchOn=name جست‌وجوی نام
+140 -13
View File
@@ -804,6 +804,100 @@ class VisitHoldTests(TestCase):
def _ms(self, moment): def _ms(self, moment):
return str(int(moment.timestamp() * 1000)) return str(int(moment.timestamp() * 1000))
def _post_edit(self, customer, action, **payload):
base = {'desc': 'تست', 'userID': self.user.id}
base.update(payload)
for key in [k for k, v in list(base.items()) if v is None]:
base.pop(key)
return self.client.post(
reverse('admin_panel:customerEditAction',
kwargs={'c_id': customer.pk, 'action_id': action.pk}),
base,
HTTP_REFERER='/panel/customer/%d/action' % customer.pk,
)
# ---------- ویرایش: تبدیل بازدید به هماهنگی با تاریخ پیگیری ----------
def test_editing_action_into_coordination_saves_hold(self):
"""تبدیل یک اقدام «بازدید» موجود به «هماهنگی» باید تاریخ پیگیری را ذخیره کند."""
customer = self._customer('تبدیل به هماهنگی')
action = Action.objects.create(
customer=customer, actionType='bazdid', status='waite',
actionDateTime=timezone.now() + timedelta(days=5),
desc='بازدید اولیه', forUser=self.user,
)
target = timezone.now() + timedelta(days=20)
response = self._post_edit(
customer, action,
actionType='hamahangi',
dateTimeAnjam=self._ms(timezone.now()),
visitHoldUntil=self._ms(target),
is_done='on',
FinishDateTime=self._ms(timezone.now()),
result='هماهنگ شد',
)
self.assertEqual(response.status_code, 302)
customer.refresh_from_db()
action.refresh_from_db()
self.assertEqual(action.actionType, 'hamahangi')
self.assertIsNotNone(customer.visit_hold_until, 'تاریخ پیگیری ذخیره نشد')
self.assertEqual(customer.visit_hold_until.date(), target.date())
def test_edit_modal_prefills_saved_hold(self):
"""هنگام بازکردن مجدد اقدام، فیلد باید مقدار ذخیره‌شده را نشان دهد.
رگرسیون: مودال مقدار را از دیتابیس نمی‌خواند، پس همیشه خالی دیده می‌شد
(کاربر فکر می‌کرد «ثبت نشده»).
"""
customer = self._customer('نمایش قرار در ویرایش')
Action.objects.create(
customer=customer, actionType='hamahangi', status='finish',
actionDateTime=timezone.now() - timedelta(minutes=5),
finishDateTime=timezone.now() - timedelta(minutes=5),
desc='هماهنگی', forUser=self.user,
)
set_visit_hold(customer, timezone.now() + timedelta(days=18))
html = self.client.get(
reverse('admin_panel:customerAction', kwargs={'c_id': customer.pk})
).content.decode()
# ردیف‌ها مقدار ذخیره‌شده را به‌صورت ISO در خود دارند...
self.assertIn('customer_hold=', html)
self.assertIn('function seedHoldPicker', html)
self.assertIn('seedHoldPicker(customerHold)', html)
def test_editing_old_visit_does_not_wipe_newer_hold(self):
"""ویرایش یک اقدام قدیمی نباید قرار هماهنگیِ جدیدتر را پاک کند."""
customer = self._customer('ویرایش قدیمی')
old_visit = Action.objects.create(
customer=customer, actionType='bazdid', status='finish',
actionDateTime=timezone.now() - timedelta(days=400),
finishDateTime=timezone.now() - timedelta(days=400),
desc='بازدید قدیمی', forUser=self.user,
)
Action.objects.create(
customer=customer, actionType='hamahangi', status='finish',
actionDateTime=timezone.now() - timedelta(minutes=5),
finishDateTime=timezone.now() - timedelta(minutes=5),
desc='هماهنگی جدید', forUser=self.user,
)
set_visit_hold(customer, timezone.now() + timedelta(days=20))
# فقط توضیح اقدام قدیمی را عوض می‌کنیم
self._post_edit(
customer, old_visit,
actionType='bazdid',
dateTimeAnjam=self._ms(timezone.now() - timedelta(days=400)),
is_done='on',
FinishDateTime=self._ms(timezone.now() - timedelta(days=400)),
desc='توضیح اصلاح شد',
)
customer.refresh_from_db()
self.assertIsNotNone(customer.visit_hold_until,
'ویرایش اقدام قدیمی قرار معتبر را پاک کرد')
self.assertIn(customer.pk, self._ids('manual'))
# ---------- ریسک ۱: تاریخِ «امروز» تا پایان روز معتبر است ---------- # ---------- ریسک ۱: تاریخِ «امروز» تا پایان روز معتبر است ----------
def test_hold_until_today_hides_customer_until_end_of_day(self): def test_hold_until_today_hides_customer_until_end_of_day(self):
customer = self._customer('قرار امروز') customer = self._customer('قرار امروز')
@@ -1030,13 +1124,11 @@ class VisitHoldTests(TestCase):
counts = self._entezar().context['counts'] counts = self._entezar().context['counts']
self.assertEqual(counts['uncoordinated'], 1) # a self.assertEqual(counts['uncoordinated'], 1) # a
# عددِ کارت باید با فهرستی که با کلیک باز می‌شود بخواند: # هر کارت فقط منبع خودش را می‌شمارد؛ d هر دو منبع را دارد ⇒ در هر سه
# view=manual گروه‌های B و D را نشان می‌دهد. # کارت شمرده می‌شود (قرار دستی، بازدید، با قرار).
self.assertEqual(counts['manual'], 2) # b + d self.assertEqual(counts['manual'], 2) # b + d
self.assertEqual(counts['scheduled'], 2) # c + d self.assertEqual(counts['scheduled'], 2) # c + d
self.assertEqual(counts['manual_only'], 1) # b self.assertEqual(counts['coordination'], 0) # هیچ‌کدام هماهنگیِ فعال ندارد
self.assertEqual(counts['scheduled_only'], 1) # c
self.assertEqual(counts['both'], 1) # d
self.assertEqual(counts['total'], 4) self.assertEqual(counts['total'], 4)
self.assertEqual(counts['coordinated'], 3) self.assertEqual(counts['coordinated'], 3)
@@ -1044,6 +1136,7 @@ class VisitHoldTests(TestCase):
self.assertEqual(self._ids('all'), {a.pk, b.pk, c.pk, d.pk}) self.assertEqual(self._ids('all'), {a.pk, b.pk, c.pk, d.pk})
self.assertEqual(self._ids('manual'), {b.pk, d.pk}) self.assertEqual(self._ids('manual'), {b.pk, d.pk})
self.assertEqual(self._ids('scheduled'), {c.pk, d.pk}) self.assertEqual(self._ids('scheduled'), {c.pk, d.pk})
self.assertEqual(self._ids('coordination'), set())
self.assertEqual(self._ids('coordinated'), {b.pk, c.pk, d.pk}) self.assertEqual(self._ids('coordinated'), {b.pk, c.pk, d.pk})
def test_invalid_view_falls_back_to_default(self): def test_invalid_view_falls_back_to_default(self):
@@ -1075,14 +1168,47 @@ class VisitHoldTests(TestCase):
counts = self._entezar().context['counts'] counts = self._entezar().context['counts']
self.assertEqual(counts['manual'], len(self._ids('manual'))) self.assertEqual(counts['manual'], len(self._ids('manual')))
self.assertEqual(counts['scheduled'], len(self._ids('scheduled'))) self.assertEqual(counts['scheduled'], len(self._ids('scheduled')))
self.assertEqual(counts['coordination'], len(self._ids('coordination')))
self.assertEqual(counts['coordinated'], len(self._ids('coordinated'))) self.assertEqual(counts['coordinated'], len(self._ids('coordinated')))
self.assertEqual(counts['uncoordinated'], len(self._ids('uncoordinated'))) self.assertEqual(counts['uncoordinated'], len(self._ids('uncoordinated')))
self.assertEqual(counts['total'], len(self._ids('all'))) self.assertEqual(counts['total'], len(self._ids('all')))
# وقتی مشتری‌ای هر دو نوع قرار را دارد، راهنمای بالای صفحه باید def test_card_number_matches_list_for_every_new_view(self):
# توضیح بدهد که چرا جمع دو کارت با «با قرار» برابر نیست. """هر سه منبع، فیلتر مستقل خودش را دارد و عدد کارت با فهرست می‌خواند."""
self.assertGreaterEqual(counts['both'], 1) # فقط هماهنگی (بدون بازدید) — موردِ باگ گزارش‌شده
self.assertIn('پس در هر دو کارت شمرده می‌شوند', self._entezar().content.decode()) coord = self._customer('فقط هماهنگی')
Action.objects.create(customer=coord, actionType='hamahangi', status='finish',
actionDateTime=timezone.now() + timedelta(days=8),
finishDateTime=timezone.now() - timedelta(minutes=5))
# فقط بازدید
sched = self._customer('فقط بازدید')
Action.objects.create(customer=sched, actionType='bazdid', status='waite',
actionDateTime=timezone.now() + timedelta(days=5))
# هر دو
both = self._customer('بازدید و هماهنگی')
Action.objects.create(customer=both, actionType='bazdid', status='waite',
actionDateTime=timezone.now() + timedelta(days=6))
Action.objects.create(customer=both, actionType='hamahangi', status='finish',
actionDateTime=timezone.now() + timedelta(days=9),
finishDateTime=timezone.now() - timedelta(minutes=5))
counts = self._entezar().context['counts']
# «هماهنگی» هرگز در نمای «بازدید برنامه‌ریزی‌شده» ظاهر نمی‌شود.
self.assertNotIn(coord.pk, self._ids('scheduled'))
self.assertIn(coord.pk, self._ids('coordination'))
# و برعکس.
self.assertNotIn(sched.pk, self._ids('coordination'))
self.assertIn(sched.pk, self._ids('scheduled'))
# مشتریِ هر دو منبع، در هر دو نما هست.
self.assertIn(both.pk, self._ids('scheduled'))
self.assertIn(both.pk, self._ids('coordination'))
# اما در «با قرار» فقط یک‌بار شمرده می‌شود.
self.assertIn(both.pk, self._ids('coordinated'))
for key in ('manual', 'scheduled', 'coordination', 'coordinated',
'uncoordinated', 'total'):
self.assertEqual(counts[key], len(self._ids(
'all' if key == 'total' else key)), f'عدد کارت {key} با فهرست نمی‌خواند')
def test_stats_cards_have_minimal_descriptions(self): def test_stats_cards_have_minimal_descriptions(self):
"""هر کارت آمار باید یک توضیح کوتاه داشته باشد تا معنی‌اش روشن باشد.""" """هر کارت آمار باید یک توضیح کوتاه داشته باشد تا معنی‌اش روشن باشد."""
@@ -1090,12 +1216,13 @@ class VisitHoldTests(TestCase):
# سرِ نوار آمار + توضیح «عقب‌افتاده» # سرِ نوار آمار + توضیح «عقب‌افتاده»
self.assertIn('ez-stats-title', html) self.assertIn('ez-stats-title', html)
self.assertIn('ez-stats-hint', html) self.assertIn('ez-stats-hint', html)
# پنج توضیح، یکی برای هر کارت # پنج توضیح برای کارت‌های بدون‌قرار/با‌قرار/… و ششمی برای هماهنگی
self.assertEqual(html.count('class="ez-desc"'), 5) self.assertEqual(html.count('class="ez-desc"'), 6)
for phrase in ('هنوز وقت بازدید تعیین نشده', for phrase in ('هنوز وقت بازدید تعیین نشده',
'وقت بازدیدشان مشخص است', 'وقت بازدیدشان مشخص است',
'تعویق دستی از فرم هماهنگی', 'تعویق دستی از فرم هماهنگی',
'اقدام «بازدید» با تاریخ آینده'): 'اقدام «بازدید» با تاریخ آینده',
'پیگیری به آینده موکول شده است'):
self.assertIn(phrase, html, f'توضیح «{phrase}» روی کارت‌ها نیست') self.assertIn(phrase, html, f'توضیح «{phrase}» روی کارت‌ها نیست')
# ---------- اسکنر پیامک هم قرار دستی را می‌بیند ---------- # ---------- اسکنر پیامک هم قرار دستی را می‌بیند ----------
@@ -1338,7 +1465,7 @@ class EntezarLayoutTests(TestCase):
def test_column_widths_sum_to_one_hundred(self): def test_column_widths_sum_to_one_hundred(self):
widths = [float(w) for w in re.findall( widths = [float(w) for w in re.findall(
r'<col style="width: (\d+(?:\.\d+)?)%">', self.html)] r'<col style="width: (\d+(?:\.\d+)?)%">', self.html)]
self.assertEqual(len(widths), 8, 'تعداد ستون‌ها باید ۸ باشد') self.assertEqual(len(widths), 9, 'تعداد ستون‌ها باید ۹ باشد')
self.assertAlmostEqual(sum(widths), 100.0, places=1) self.assertAlmostEqual(sum(widths), 100.0, places=1)
def test_row_number_is_not_a_column(self): def test_row_number_is_not_a_column(self):