improve bazdid 6

This commit is contained in:
javad committed 2026-10-22 14:01:08 +03:30
1 parent 99c25a8216
commit fa73a734a0
21 files changed
+32758 -7983

No files matched your search

+8 -6
View File
@@ -1,10 +1,10 @@
Project Root: khatibi
Generated At: 2026-10-21T10:54:58+00:00
Python Files: 188
Generated At: 2026-10-22T06:08:56+00:00
Python Files: 189
Django Files: 12
JavaScript/TypeScript Files: 14
HTML Files: 58
Total Files: 754
Total Files: 756
Total Directories: 79
khatibi
@@ -130,6 +130,7 @@ khatibi
│ │ ├── 0077_alter_smstemplate_text_balance_help.py
│ │ ├── 0078_action_visit_covering_index.py
│ │ ├── 0079_customer_visit_hold.py
│ │ ├── 0080_action_soft_delete.py
│ │ └── __init__.py
│ ├── models
│ │ ├── __init__.py
@@ -331,9 +332,10 @@ khatibi
│ ├── khatibi92669266_backups_db_backup_20261020_223011.sqlite3
│ ├── khatibi92669266_backups_db_backup_20261020_223011.sqlite3-shm
│ ├── khatibi92669266_backups_db_backup_20261020_223011.sqlite3-wal
│ ├── kt.sqlite3
│ ├── kt.sqlite3-shm
│ └── kt.sqlite3-wal
│ ├── khatibi92669266_backups_db_backup_20261021_223009.sqlite3
│ ├── khatibi92669266_backups_db_backup_20261021_223009.sqlite3-shm
│ ├── khatibi92669266_backups_db_backup_20261021_223009.sqlite3-wal
│ └── kt.sqlite3
├── docs
│ └── visit_hold_entezar.md
├── graphify-out
@@ -318,6 +318,29 @@
#ActionBox .ax-warn.is-confirming .ax-warn-actions { display: flex; }
#ActionBox .ax-warn:focus { outline: 2px solid #f59e0b; outline-offset: 2px; }
/* اعلان «تاریخ قرار عقب‌تر» — کلاس مستقل تا به CSS پنل «انجام شد» وابسته نباشد */
#ActionBox .ax-holdback {
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-holdback.is-blocking {
border-color: #f59e0b; box-shadow: 0 0 0 3px #fde68a66; background: #fef3c7;
}
#ActionBox .ax-holdback .ax-hb-title { font-weight: 800; color: #78350f; margin: 0 0 .15rem; }
#ActionBox .ax-holdback .ax-hb-current { margin: 0 0 .4rem; }
#ActionBox .ax-holdback .ax-hb-current b { font-weight: 800; }
#ActionBox .ax-holdback .ax-hb-alt { margin: 0; }
#ActionBox .ax-holdback .ax-hb-check {
display: flex; align-items: flex-start; gap: .5rem; margin: 0;
padding: .5rem .6rem; border-radius: .45rem; background: #fff;
border: 1px solid #fbbf24; cursor: pointer; font-weight: 700; color: #7c2d12;
}
#ActionBox .ax-holdback .ax-hb-check input {
width: 1.05rem; height: 1.05rem; margin-top: .15rem; flex: 0 0 auto;
}
#ActionBox .ax-holdback: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;
@@ -352,12 +375,15 @@
</style>
{% if customer %}
<form id="ActionBox" class="modal" method="post" action="{% url 'admin_panel:customerNewAction' customer.id %}">
<form id="ActionBox" class="modal" method="post" action="{% url 'admin_panel:customerNewAction' customer.id %}"
data-customer-hold="{% if customer.visit_hold_until %}{{ customer.visit_hold_until|date:'c' }}{% endif %}">
{% else %}
<form id="ActionBox" class="modal" method="post" action="">
{% endif %}
{% csrf_token %}
{# تأیید صریح کاربر برای عقب‌بردن تاریخ قرار؛ فقط با زدن دکمهٔ تأیید پر می‌شود. #}
<input type="hidden" name="force_hold" id="forceHold" value="">
<div class="modal-background closeActionBox"></div>
<div class="modal-card ax-card">
<header class="modal-card-head ax-head">
@@ -427,6 +453,20 @@
</div>
</div>
{# اعلان «تاریخ عقب‌تر» — عرض کامل، بیرون از شبکهٔ دوستونه #}
<div class="ax-holdback is-hidden" id="holdBackwardNotice" tabindex="-1"
role="alert" aria-live="polite">
<p class="ax-hb-title">این تاریخ از قرار فعلی عقب‌تر است</p>
<p class="ax-hb-current">قرار فعلی: <b id="hbCurrent">—</b></p>
<label class="ax-hb-check is-hidden" id="hbForceRow" for="forceHoldCheck">
<input type="checkbox" id="forceHoldCheck">
<span>تاریخ قرار را عقب ببر</span>
</label>
<p class="ax-hb-alt is-hidden" id="hbCreateHint">
این تاریخ ذخیره نمی‌شود. برای تغییر قرار، وظیفهٔ هماهنگی فعلی را ویرایش کنید.
</p>
</div>
{# راهنمای بازشونده — فقط برای هماهنگی #}
<div class="ax-help-wrap showWhenCoordination is-hidden" style="margin-top: .6rem;">
<button type="button" class="ax-help-toggle" id="holdHelpToggle"
@@ -451,11 +491,13 @@
<h4>نکات مهم</h4>
<ul>
<li><b>خالی + تیک «انجام شد»</b> ← سیستم خودش ۱۴ روز می‌گذارد.</li>
<li><b>خالی + تیک</b> ← سیستم خودش ۱۴ روز می‌گذارد.</li>
<li><b>خالی + بدون تیک</b> ← هیچ تعویقی ثبت نمی‌شود؛ مشتری در فهرست می‌ماند.</li>
<li><b>فقط تمدید می‌کند، کوتاه نمی‌کند:</b> اگر قرار ۲۲ آذر باشد و همکار دیگری ۳۰ آذر بگذارد،
قرار به ۳۰ آذر می‌رود؛ اما ۱۵ آذر نادیده گرفته می‌شود. برای کوتاه‌کردن،
هماهنگی را حذف و دوباره ثبت کنید.</li>
قرار به ۳۰ آذر می‌رود؛ تاریخ‌های عقب‌تر نادیده گرفته می‌شوند تا قرارِ دورتر از بین نرود.
اگر خودتان می‌خواهید قرار را عقب ببرید، وظیفه را <b>ویرایش</b> کنید و
گزینهٔ <b>«تاریخ قرار را عقب ببر»</b> را تیک بزنید
(در «ایجاد وظیفهٔ تازه» این گزینه وجود ندارد).</li>
<li><b>حذف یا لغو هماهنگی</b> ← قرار بی‌اثر می‌شود و مشتری بلافاصله برمی‌گردد.</li>
<li><b>سقف ۶ ماه</b> است؛ بیشتر از آن با پیام خطا رد می‌شود.</li>
<li>این فیلد <b>تاریخ بازدید پرسنل نیست</b> و <b>بازدید حساب نمی‌شود</b>؛
@@ -596,6 +638,7 @@
dateTimePickerAnjam.setDate();
dateTimePickerFinish.setDate();
resetHoldPicker();
syncHoldBackward();
$(".showWhenCoordination").addClass("is-hidden");
closeHoldHelp();
$("#sabtBtn05").val("ثبت");
@@ -649,8 +692,109 @@
$("#checkIsDone").trigger('focus');
});
// ══════════ عقب‌بردن تاریخ قرار ══════════
// قاعدهٔ سیستم: تاریخ «تا تاریخ پیگیری نشود» فقط رو به جلو می‌رود تا قرارِ
// دورتری که ثبت شده از بین نرود. در «ویرایش» اگر کاربر خودش بخواهد عقب ببرد،
// قرار فعلی را می‌بیند و با تیک زدن اجازهٔ صریح می‌دهد. در «ایجادِ» وظیفهٔ تازه
// این اجازه داده نمی‌شود؛ چون کاربر قصد اصلاح قرارِ موجود را ندارد و ممکن است
// ناخواسته قولِ ثبت‌شدهٔ قبلی را کوتاه کند.
function holdCurrentMs() {
var iso = ($("#ActionBox").attr('data-customer-hold') || '').trim();
if (!iso) { return null; }
var ms = new Date(iso).getTime();
return isNaN(ms) ? null : ms;
}
// مقدار پیکر در فیلد مخفی به‌صورت timestamp میلی‌ثانیه‌ای است؛ اگر پیکر
// تاریخ شمسی متنی گذاشت، همان را هم می‌پذیریم.
function holdTargetMs() {
var raw = ($("#visitHoldUntil").val() || '').trim();
if (!raw) { return null; }
if (/^\d+$/.test(raw)) {
var n = parseInt(raw, 10);
return isNaN(n) ? null : n;
}
var parsed = Date.parse(raw.replace(/\//g, '-'));
return isNaN(parsed) ? null : parsed;
}
function sameLocalDay(a, b) {
var da = new Date(a), db = new Date(b);
return da.getFullYear() === db.getFullYear()
&& da.getMonth() === db.getMonth()
&& da.getDate() === db.getDate();
}
function jalaliDay(ms) {
if (ms === null) { return '—'; }
try {
if (typeof persianDate !== 'undefined') {
return new persianDate(ms).format('YYYY/MM/DD');
}
} catch (err) { /* کتابخانه در دسترس نیست ⇒ تاریخ میلادی */ }
var d = new Date(ms);
return d.getFullYear() + '/' + (d.getMonth() + 1) + '/' + d.getDate();
}
// آیا تاریخ انتخابی از قرار فعلی عقب‌تر است؟ (مستقل از تیک کاربر)
function holdIsBackward() {
if ($("#actionType").val() !== 'hamahangi') { return false; }
var current = holdCurrentMs(), target = holdTargetMs();
if (current === null || target === null) { return false; }
if (sameLocalDay(current, target)) { return false; }
return target < current;
}
// به‌روزرسانی اعلان (و اجازهٔ عقب‌بردن فقط در ویرایش) — با هر تغییر تاریخ/نوع.
function syncHoldBackward() {
var notice = $("#holdBackwardNotice");
var needed = holdIsBackward();
var canForce = needed && isEditMode;
// تیک فقط وقتی معنا دارد که عقب‌بردن مجاز باشد؛ در غیر این صورت پاک می‌شود.
$("#hbForceRow").toggleClass("is-hidden", !canForce);
$("#hbCreateHint").toggleClass("is-hidden", !needed || canForce);
if (!canForce) {
$("#forceHoldCheck").prop('checked', false);
$("#forceHold").val('');
}
if (!needed) {
notice.addClass("is-hidden").removeClass("is-blocking");
return;
}
$("#hbCurrent").text(jalaliDay(holdCurrentMs()));
notice.removeClass("is-hidden");
}
// تیک «عقب ببر» ⇒ فیلد مخفی force_hold برای سرور.
$("#forceHoldCheck").on('change', function() {
var checked = $(this).prop('checked');
$("#forceHold").val(checked ? '1' : '');
$("#holdBackwardNotice").toggleClass("is-blocking", !checked);
});
// هر تغییر تاریخ/نوع اقدام ⇒ وضعیت دوباره محاسبه می‌شود.
$("#ActionBox").on('change input', "#visitHoldUntil, #actionType",
function() { syncHoldBackward(); });
// پیکر تاریخ هم بعد از انتخاب، فیلد مخفی را پر می‌کند.
$(document).on('change', ".dateTimePickerHoldUntil, .dateTimePickerHoldUntil-alt",
function() { syncHoldBackward(); });
// گرفتن تأیید پیش از ثبتِ هماهنگیِ «انجام‌شده» (فقط در حالت ایجاد، نه ویرایش).
$("#ActionBox").on('submit', function(event) {
// (۱) در ویرایش: تاریخ عقب‌تر از قرار فعلی ⇒ تا تیک تأیید نزده، ارسال نمی‌شود.
// در «ایجاد»، وظیفه ذخیره می‌شود و فقط تاریخ قرار عقب نمی‌رود؛ سرور
// همین را با پیام روشن به کاربر می‌گوید، پس ارسال بلاک نمی‌شود.
if (isEditMode && holdIsBackward() && !$("#forceHoldCheck").prop('checked')) {
event.preventDefault();
var notice = $("#holdBackwardNotice").removeClass("is-hidden").addClass("is-blocking");
if (notice[0].scrollIntoView) {
notice[0].scrollIntoView({ block: 'center', behavior: 'smooth' });
}
notice.trigger('focus');
return;
}
// (۲) هماهنگیِ «انجام‌شده»: تأیید یک‌بارهٔ انجام کار.
if (isEditMode || holdConfirmed) {
return; // ویرایش، یا تأیید قبلی ⇒ ارسال آزاد
}
@@ -704,6 +848,7 @@
// مقدار ذخیره‌شدهٔ «قرار بعدی» را در همان فیلد نشان بده (بعد از toggle،
// چون toggle برای انواع غیرهماهنگی فیلد را پاک می‌کند).
seedHoldPicker(customerHold);
syncHoldBackward();
$("#desc").val(ActionParseData[0]['fields']['desc']);
$("#userID").val(ActionParseData[0]['fields']['forUser']);
if(ActionParseData[0]['fields']['status'] == 'finish'){
@@ -752,6 +897,11 @@
enabled: false,
}
},
// بعد از انتخاب تاریخ، وضعیت «عقب‌تر بودن» فوراً بازمحاسبه شود
// (پیکر مقدار altField را برنامه‌نویسی‌شده می‌نویسد و ممکن است change رخ ندهد).
onSelect: function () {
setTimeout(function () { syncHoldBackward(); }, 0);
},
});
// اگر پیکر ساخته نشد (مثلاً نام کلاس در قالب عوض شده باشد) هشدار بده؛
@@ -7,7 +7,7 @@
<div class="columns m-0">
<div class="column is-12 px-2 pt-3 pb-0">
{% for message in messages %}
<div class="notification {% if message.tags == 'error' %}is-danger{% elif message.tags == 'success' %}is-success{% else %}is-info{% endif %} is-light mb-2">
<div class="notification {% if message.tags == 'error' %}is-danger{% elif message.tags == 'warning' %}is-warning{% elif message.tags == 'success' %}is-success{% else %}is-info{% endif %} is-light mb-2">
<button class="delete close-notification" type="button"></button>
<strong>{{ message }}</strong>
</div>
+114 -26
View File
@@ -8,6 +8,7 @@ from django.contrib import messages
from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required
from django.utils import timezone
from persiantools.jdatetime import JalaliDate
from ..models import Action, Customer
from ..sms.triggers import notify_visit_scheduled
@@ -91,6 +92,8 @@ def _action_payload_from_request(request):
'finishDateTime': _parse_form_datetime(request.POST.get('FinishDateTime')),
# فقط برای «هماهنگی» معنا دارد؛ تاریخِ «تا کِی پیگیری نشود».
'holdRaw': _parse_form_datetime(request.POST.get('visitHoldUntil')),
# تأیید صریح کاربر برای «عقب بردن» تاریخ قرار دستی.
'force_hold': bool(request.POST.get('force_hold')),
'desc': request.POST.get('desc', None),
'result': request.POST.get('result', None),
'userID': request.POST.get('userID', None),
@@ -98,13 +101,13 @@ def _action_payload_from_request(request):
}
def _apply_visit_hold(customer_obj, payload, *, status, is_new):
def _apply_visit_hold(customer_obj, payload, *, status, is_new, force_hold=False):
"""هم‌راستاکردن «قرار بعدی» با اقدام ثبت/ویرایش‌شده.
قواعد:
* برای اقدام نوع «هماهنگی»:
- تاریخ قرار داده شده ⇒ ``set_visit_hold`` (فقط رو به جلو)
- تاریخ قرار داده شده ⇒ ``set_visit_hold`` (فقط رو به جلو، مگر تأیید کاربر)
- «انجام شد» تیک خورده و تاریخ خالی ⇒ پیش‌فرض ۱۴ روز
- لغو شده ⇒ (در ایجاد) پاک می‌شود
* برای **هر اقدام دیگری** (از جمله «بازدید»):
@@ -114,18 +117,77 @@ def _apply_visit_hold(customer_obj, payload, *, status, is_new):
هماهنگیِ *جدیدتر* آمده باشد؛ پس دست نمی‌زنیم. اعتبارسنجی
:func:`manual_holds_map` (شرط «آخرین فعالیت = هماهنگی») خودش تصمیم
می‌گیرد. بدون این تفکیک، ویرایش یک اقدام قدیمی قرار معتبر را پاک می‌کرد.
خروجی (دیکشنری، برای بازخورد شفاف به کاربر)::
{'ok': bool, 'reason': 'written'|'default'|'forward_only'|'cleared'|'',
'current': datetime|None, 'target': datetime|None}
``reason='forward_only'`` یعنی تاریخ درخواستی از قرار فعلی عقب‌تر است و
نوشته **نشد**؛ لایهٔ ویو باید همین را با تاریخ‌ها به کاربر نشان بدهد و اگر
خودش تأیید کرد، با ``force_hold=True`` دوباره صدا بزند.
"""
result = {'ok': True, 'reason': '', 'current': None, 'target': None,
'force_allowed': not is_new}
# «عقب‌بردن قرار» فقط در ویرایش مجاز است. در ایجادِ وظیفهٔ تازه، کاربر قصد
# اصلاح قرارِ موجود را ندارد و اجازه دادن به آن می‌تواند قولِ قبلیِ ثبت‌شده
# (شاید توسط کارشناس دیگر) را بی‌آنکه کسی بخواهد کوتاه کند.
force_hold = bool(force_hold) and not is_new
is_coordination = payload['actionType'] == Action.ActionType.HAMAHANGI
if not is_coordination or status == Action.Status.CANCELL:
return clear_visit_hold(customer_obj) if is_new else True
if is_new:
result['ok'] = bool(clear_visit_hold(customer_obj))
result['reason'] = 'cleared' if result['ok'] else ''
return result
raw = payload.get('holdRaw')
if raw is not None:
set_visit_hold(customer_obj, normalize_hold_until(raw))
return True
if payload['status'] == Action.Status.FINISH:
set_visit_hold(customer_obj, default_hold_until())
return True
target = normalize_hold_until(raw)
reason = 'written'
elif payload['status'] == Action.Status.FINISH:
target = default_hold_until()
reason = 'default'
else:
return result
current = as_aware(customer_obj.visit_hold_until)
result['current'] = current
result['target'] = target
written = set_visit_hold(customer_obj, target, force=force_hold)
result['ok'] = written
result['reason'] = reason if written else 'forward_only'
return result
def _jalali_day(value):
"""تاریخ شمسی روز (به وقت محلی) برای پیام‌های کاربر."""
if value is None:
return '—'
return JalaliDate(timezone.localtime(value)).strftime('%Y/%m/%d')
def _report_hold_result(request, result):
"""بازخورد شفافِ نتیجهٔ نوشتن «تا تاریخ پیگیری نشود» به کاربر.
قاعدهٔ «فقط رو به جلو» بی‌صدا نیست: اگر تاریخ درخواستی از قرار فعلی عقب‌تر
باشد، اینجا گفته می‌شود که ذخیره نشد و کاربر چطور می‌تواند خودش انجامش دهد.
"""
if result.get('reason') != 'forward_only':
return
current, target = result.get('current'), result.get('target')
tz = timezone.get_current_timezone()
if current is not None and target is not None \
and target.astimezone(tz).date() >= current.astimezone(tz).date():
# همان روز ⇒ تغییر واقعی‌ای لازم نیست؛ پیام دادن گمراه‌کننده است.
return
tail = ('برای ثبت، گزینهٔ «تاریخ قرار را عقب ببر» را تیک بزنید.'
if result.get('force_allowed')
else 'برای تغییر قرار، همان وظیفهٔ هماهنگی را ویرایش کنید.')
messages.warning(
request,
'تاریخ قرار ذخیره نشد؛ از قرار فعلی این مشتری (%s) عقب‌تر است. %s'
% (_jalali_day(current), tail)
)
def _reject(request, error, redirect_to):
@@ -164,7 +226,11 @@ 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, status=newAction.status, is_new=True)
hold_result = _apply_visit_hold(
customer_obj, payload, status=newAction.status, is_new=True,
force_hold=payload.get('force_hold', False),
)
_report_hold_result(request, hold_result)
# پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
if newAction.actionType == Action.ActionType.BAZDID:
@@ -225,7 +291,11 @@ def customerEditAction(request ,c_id, action_id):
# «قرار بعدی»: با ویرایش هم‌راستا می‌شود. برای اقدام غیرهماهنگی عمداً
# پاک نمی‌کنیم؛ رکورد ویرایش‌شده ممکن است قدیمی‌تر از یک هماهنگیِ جدیدتر
# باشد و پاک‌کردن، قرار معتبر را از بین ببرد.
_apply_visit_hold(customer_obj, payload, status=newAction.status, is_new=False)
hold_result = _apply_visit_hold(
customer_obj, payload, status=newAction.status, is_new=False,
force_hold=payload.get('force_hold', False),
)
_report_hold_result(request, hold_result)
#return redirect('/panel/customer/'+str(c_id)+'/action')
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
@@ -272,14 +342,35 @@ def ActionListAll(request):
data = {'actions': actions, 'allUsers': allUsers}
return render(request, 'admin_panel/ActionList.html', data)
def _local_day_range(day=None):
"""بازهٔ ``[آغاز روز، پایان روز]`` به وقت محلی و aware.
قبلاً این دو ویو رشته‌های naive می‌ساختند (``'%Y-%m-%d 00:00:00'``) و Django
آن‌ها را با منطقهٔ زمانی پیش‌فرض تفسیر می‌کرد؛ هر تغییر ساعت/تایم‌زون باعث
جابه‌جایی مرز «وظایف امروز» می‌شد. حالا مرز صریح و aware است.
در روزهای تغییر ساعت (مثلاً ۲۲ مارس در تهران) نیمه‌شب ممکن است وجود نداشته
باشد؛ آن حالت با ``is_dst=False`` پوشش داده می‌شود تا یک تاریخ مرزی، صفحهٔ
وظایف را نشکند (همان کاری که ``visit_policy.as_aware`` می‌کند).
"""
day = day or timezone.localdate()
bounds = []
for moment in (datetime.datetime.combine(day, datetime.time.min),
datetime.datetime.combine(day, datetime.time.max)):
try:
bounds.append(timezone.make_aware(moment))
except Exception:
bounds.append(timezone.make_aware(moment, is_dst=False))
return bounds[0], bounds[1]
@login_required(login_url='/panel/p_login/')
def ActionListToday(request):
startTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 00:00:00')
endTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 23:59:59')
#startTodayDT = datetime.datetime.strptime(startTodayDT, '%Y-%m-%d-00:00:00')
#endTodayDT = datetime.datetime.strptime(endTodayDT, '%Y-%m-%d-23:59:59')
#return HttpResponse(todayDT)
actions = Action.objects.filter(actionDateTime__gte=startTodayDT,actionDateTime__lte=endTodayDT, forUser__id=request.user.id, status='waite').order_by("-actionDateTime")[:50]
startTodayDT, endTodayDT = _local_day_range()
actions = Action.objects.filter(
actionDateTime__range=(startTodayDT, endTodayDT),
forUser__id=request.user.id, status='waite',
).order_by("-actionDateTime")[:50]
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {'actions': actions, 'allUsers': allUsers}
return render(request, 'admin_panel/ActionList.html', data)
@@ -290,22 +381,19 @@ def ActionListUserToday(request, u_id):
status = request.GET.get('status', None)
unixADT = dateTimeAction
if dateTimeAction:
dateTimeAction = datetime.datetime.fromtimestamp(int(dateTimeAction)/1e3)
startTodayDT = dateTimeAction.strftime('%Y-%m-%d 00:00:00')
endTodayDT= dateTimeAction.strftime('%Y-%m-%d 23:59:59')
# timestamp میلی‌ثانیه‌ای GET → تاریخ محلی (نه naive UTC).
moment = as_aware(datetime.datetime.fromtimestamp(int(dateTimeAction) / 1e3))
day = moment.astimezone(timezone.get_current_timezone()).date()
else:
startTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 00:00:00')
endTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 23:59:59')
#startTodayDT = datetime.datetime.strptime(startTodayDT, '%Y-%m-%d-00:00:00')
#endTodayDT = datetime.datetime.strptime(endTodayDT, '%Y-%m-%d-23:59:59')
#return HttpResponse(todayDT)
day = timezone.localdate()
startTodayDT, endTodayDT = _local_day_range(day)
if status == 'waite' or status == None or status == '' or status == ' ':
status = 'waite'
actions = Action.objects.filter(actionDateTime__gte=startTodayDT,actionDateTime__lte=endTodayDT, forUser__id=u_id, status=status).order_by("-actionDateTime")[:50]
actions = Action.objects.filter(actionDateTime__range=(startTodayDT, endTodayDT), forUser__id=u_id, status=status).order_by("-actionDateTime")[:50]
actionPr = Action.objects.filter(actionDateTime__lt=startTodayDT, forUser__id=u_id, status=status).order_by("-actionDateTime")[:200]
elif status == 'finish':
actionPr = None
actions = Action.objects.filter(actionDateTime__gte=startTodayDT,actionDateTime__lte=endTodayDT, forUser__id=u_id, status=status).order_by("-actionDateTime")[:50]
actions = Action.objects.filter(actionDateTime__range=(startTodayDT, endTodayDT), forUser__id=u_id, status=status).order_by("-actionDateTime")[:50]
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {'actions': actions, 'allUsers': allUsers, 'u_id': u_id, "unixADT": unixADT, "status": status, 'actionPr': actionPr}
+14 -5
View File
@@ -628,7 +628,8 @@ def entezar(request):
entry = sources.get(cid) or {}
if not entry:
return 0
if len(entry) > 1:
days = {value.astimezone(current_tz).date() for value in entry.values()}
if len(days) > 1:
return 4
if SOURCE_MANUAL in entry:
return 1
@@ -699,8 +700,10 @@ def entezar(request):
try:
entezar_bazdid = CacheTable.objects.get(key='9mahbazdid')
# کارت داشبورد = «کارِ فوری» = تعیین تکلیف نشده.
entezar_bazdid.value = counts['uncoordinated']
entezar_bazdid.save()
# فقط در صورت تغییر می‌نویسیم تا یک GET ساده بی‌دلیل دیتابیس را تغییر ندهد.
if str(entezar_bazdid.value) != str(counts['uncoordinated']):
entezar_bazdid.value = str(counts['uncoordinated'])
entezar_bazdid.save(update_fields=['value'])
except CacheTable.DoesNotExist:
pass
@@ -812,14 +815,20 @@ def entezar(request):
entry.update(followup_entry)
flags = [key for key in (SOURCE_MANUAL, SOURCE_SCHEDULED,
SOURCE_COORDINATION) if key in entry]
# «N قرار» = تعداد **روزهای یکتای** قرار، نه تعداد منابع. یک هماهنگی
# که تاریخ تعویقش همان روز است، هم‌زمان «manual» و «coordination»
# تولید می‌کند ولی دو قرارِ متفاوت نیست (برچسب «۲ قرار» گمراه‌کننده بود).
appointment_days = {
entry[key].astimezone(current_tz).date() for key in flags
}
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),
# تعداد منابع (۰ تا ۳) — قالب بر اساس آن برچسب وضعیت را می‌سازد.
'source_count': len(flags),
# تعداد قرارهای یکتا (۱ تا ۳ روز متمایز) — قالب بر اساس آن برچسب می‌سازد.
'source_count': len(appointment_days),
# تاریخ پیگیری این نمای خاص (برای ستون اختصاصی نمای «امروز»).
'followup_at': (min(followup_entry.values())
if followup_entry else None),
+75 -12
View File
@@ -337,6 +337,14 @@ SOURCE_MANUAL = "manual"
SOURCE_SCHEDULED = "scheduled"
SOURCE_COORDINATION = "coordination"
# اقدام‌هایی که **واقعاً** یک «قرار بعدی» را جایگزین می‌کنند.
# چرا فقط این‌ها؟ قرار دستی یعنی «تا این تاریخ تماس نگیر». تنها چیزی که این
# قرار را منتفی می‌کند، خودِ بازدیدی است که به‌جای تماس انجام شده (یا نصب که
# چرخه را از نو شروع می‌کند). یک سند وصول، یک تماس ورودی یا خروجی، یا هر
# اقدام دیگر، قرار را باطل نمی‌کند؛ در غیر این‌صورت قرارِ ثبت‌شده بی‌صدا می‌مرد
# و مشتری دوباره در فهرست تماس ظاهر می‌شد.
_SUPERSEDING_TYPES = (Action.ActionType.BAZDID, Action.ActionType.NASB)
def _future_actions(action_type, statuses, *, now_utc, whole_day=False):
"""اقدام‌های آیندهٔ یک نوع با تاریخ مؤثر (تاریخ اقدام، وگرنه تاریخ اتمام).
@@ -409,10 +417,13 @@ def _valid_manual_holds(holds):
قرارهایی که معتبرند:
۱. یک اقدام «هماهنگی» فعال (``waite``/``finish``) برای مشتری وجود دارد،
۲. و **آخرین فعالیتِ مشتری همان هماهنگی است** — اگر پس از هماهنگی، اقدام
دیگری (به‌ویژه «بازدید») ثبت شده باشد، قرار باطل است.
۲. و **آخرین فعالیتِ جایگزین‌کنندهٔ قرار** همان هماهنگی است — یعنی پس از
هماهنگی، بازدید (``bazdid``) یا نصب (``nasb``) ثبت نشده باشد.
بند ۲ همان چیزی است که «ثبت بازدید پس از هماهنگی» را خودکار بی‌اثر می‌کند.
ولی فقط اقدام‌های جایگزین‌کننده (``_SUPERSEDING_TYPES``) قرار را باطل
می‌کنند: سند وصول، تماس ورودی/خروجی و بقیهٔ اقدام‌ها قرار را نمی‌کشند —
وگرنه یک پرداخت ساده، قرارِ توافق‌شده را بی‌صدا از بین می‌برد.
این تابع جدا شده تا هم :func:`manual_holds_map` (قرارهای آینده) و هم
:func:`followups_on_date` (قرارهای یک روز مشخص، حتی گذشته) از **یک قاعدهٔ
@@ -436,10 +447,12 @@ def _valid_manual_holds(holds):
.annotate(last=Max("createdDT"))
.values_list("customer_id", "last")
)
# آخرین فعالیتِ غیرهماهنگی (بازدید، نصب، تماس، …) برای هر مشتری.
# آخرین فعالیتِ **جایگزین‌کنندهٔ قرار** (بازدید/نصب) برای هر مشتری.
# عمداً فقط این دو نوع: یک سند وصول یا تماس ورودی نباید قرار را باطل کند.
other_latest = dict(
Action.objects.filter(customer_id__in=customer_ids)
.exclude(actionType=Action.ActionType.HAMAHANGI)
Action.objects.filter(
customer_id__in=customer_ids, actionType__in=_SUPERSEDING_TYPES,
)
.values("customer_id")
.annotate(last=Max("createdDT"))
.values_list("customer_id", "last")
@@ -534,7 +547,8 @@ def followups_on_date(target_date, *, now=None):
امروز یا آینده — را می‌بیند تا کارشناس بتواند با تقویم عقب/جلو برود.
خروجی: ``{customer_id: {منبع: تاریخ}}`` مرتب بر اساس زودترین ساعت موعد.
مشتریانی که قرار **بازدید** دارند در خروجی نیستند.
مشتریانی که قرار **بازدید** دارند، یا بازدیدشان از همان روز موعد به بعد
**انجام شده**، در خروجی نیستند (فهرست، «کارِ باقی‌مانده» است نه آرشیو).
"""
day_start, day_end = _local_day_bounds(target_date)
@@ -584,6 +598,25 @@ def followups_on_date(target_date, *, now=None):
for customer_id in _pending_visit_customer_ids(since=day_start):
result.pop(customer_id, None)
# ۴) حذف مشتریانی که بازدیدشان از همان روزِ موعد به بعد **انجام شده** است.
# قاعدهٔ کسب‌وکار: اگر بازدید انجام شده، تماسِ آن روز لازم نیست. قبلاً فقط
# بازدیدِ «در انتظار» دیده می‌شد، پس مشتریِ بازدیدشده باز هم در فهرست
# «نیازمند پیگیری امروز» می‌ماند (۹۲٪ ردیف‌های دادهٔ واقعی).
# فقط برای امروز/آینده اعمال می‌شود تا مرور روزهای گذشته (تقویم) سندِ کاریِ
# همان روز را با داده‌های بعدی بازنویسی نکند.
if result:
now = _now(now)
current_tz = timezone.get_current_timezone()
if target_date >= now.astimezone(current_tz).date():
visits = last_visit_map(now=now, customer_ids=list(result))
for customer_id, visited_at in visits.items():
bucket = result.get(customer_id)
if not bucket or visited_at is None:
continue
due_day = min(bucket.values()).astimezone(current_tz).date()
if visited_at.astimezone(current_tz).date() >= due_day:
result.pop(customer_id, None)
return result
@@ -600,6 +633,10 @@ def overdue_followups(*, now=None, target_date=None):
(موعدش تازه است).
* اگر برایش **قرار بازدید** ثبت شده باشد، از فهرست خارج می‌شود — همان قاعدهٔ
:func:`followups_on_date` («بازدید گذاشته شده ⇒ تماس لازم نیست»).
* اگر بازدیدش **از همان موعد به بعد انجام شده** باشد، از فهرست خارج می‌شود
(پیگیری با بازدید بسته شده است).
* اگر **قرار دستیِ معتبر** داشته باشد («تا تاریخ پیگیری نشود»)، از فهرست
خارج می‌شود؛ چون خودِ کاربر صریحاً خواسته تا آن تاریخ تماس نگیریم.
* هماهنگیِ لغوشده (``cancell``) اصلاً دیده نمی‌شود.
خروجی: ``{customer_id: تاریخِ موعدِ ازدست‌رفته}`` — تا هم شمارش کنیم و هم
@@ -641,6 +678,24 @@ def overdue_followups(*, now=None, target_date=None):
for customer_id in pending_visits:
overdue.pop(customer_id, None)
current_tz = timezone.get_current_timezone()
# بازدیدِ **انجام‌شده** از همان موعد به بعد ⇒ این پیگیری انجام شده است.
# قبلاً فقط بازدیدِ «در انتظار» دیده می‌شد و همین، فهرست «از روزهای قبل»
# را پر از مشتریانی می‌کرد که بازدیدشان ثبت شده بود (۸۸٪ دادهٔ واقعی).
visits = last_visit_map(now=now, customer_ids=list(overdue))
for customer_id, visited_at in visits.items():
if visited_at is None:
continue
if visited_at.astimezone(current_tz).date() >= overdue[customer_id]:
overdue.pop(customer_id, None)
# «قرار دستیِ معتبر» یعنی کاربر صریحاً گفته «تا این تاریخ پیگیری نشود»؛
# چنین مشتری‌ای نمی‌تواند هم‌زمان «عقب‌افتاده» شمرده شود. قبلاً این ستون
# در این محاسبه دیده نمی‌شد (۳۸ ردیف متناقض در دادهٔ واقعی).
for customer_id in manual_holds_map(now=now):
overdue.pop(customer_id, None)
return overdue
@@ -726,12 +781,20 @@ def default_hold_until(*, now=None, days=HOLD_DEFAULT_DAYS):
return end_of_local_day(base)
def set_visit_hold(customer, until, *, now=None):
"""ثبت قرار دستی؛ فقط اگر **جلوتر** از مقدار فعلی باشد می‌نویسد.
def set_visit_hold(customer, until, *, now=None, force=False):
"""ثبت قرار دستی؛ به‌طور پیش‌فرض فقط اگر **جلوتر** از مقدار فعلی باشد می‌نویسد.
قاعدهٔ «فقط رو به جلو» (مثل ``refresh_last_bazdid``) جلوی این را می‌گیرد که
ثبت هماهنگی توسط کارشناس دوم، قرارِ دورترِ قبلی را عقب بکشد.
خروجی: ``True`` اگر نوشته شد.
قاعدهٔ «فقط رو به جلو» جلوی این را می‌گیرد که ثبت هماهنگی توسط کارشناس دوم،
قرارِ دورترِ قبلی را عقب بکشد.
``force=True`` برای وقتی است که کاربر **خودش** صریحاً تأیید کرده تاریخ عقب
برود (تیک «تاریخ قرار را عقب ببر» در مودالِ **ویرایش** ⇒ فیلد ``force_hold``).
در «ایجاد وظیفهٔ تازه» ویو این پارامتر را نمی‌فرستد. این پارامتر فقط از مسیر
ویو و پس از تأیید کاربر استفاده می‌شود؛ منطق نمایش/فهرست‌ها تغییری نمی‌کند و
ستون همچنان همان جای ذخیرهٔ تاریخ است.
خروجی: ``True`` اگر نوشته شد، وگرنه ``False`` (یا مقدار فعلی جلوتر است، یا
تاریخ نامعتبر).
"""
if customer is None or customer.pk is None:
return False
@@ -739,7 +802,7 @@ def set_visit_hold(customer, until, *, now=None):
if target is None:
return False
current = as_aware(customer.visit_hold_until)
if current is not None and current >= target:
if current is not None and current >= target and not force:
return False
Customer.objects.filter(pk=customer.pk).update(
visit_hold_until=target, visit_hold_set_at=_now(now),
File diff suppressed because it is too large. Load diff
+742
View File
@@ -0,0 +1,742 @@
# تحلیل کامل: دو فیلد تاریخِ «اقدام»، صفحهٔ انتظار و پیامک‌های بازدید
> دامنه: `Action` (ایجاد/ویرایش/حذف)، `Customer.visit_hold_until`، ویوی
> `/panel/entezar/` (`?view=today` و `?view=today&missed=1`)، و اسکنر/تریگرهای پیامک.
> روش: خواندن کدِ تولیدی + `ripwire` (blast radius) + `rg` + **ممیزی فقط-خواندنی روی
> کپیِ دیتابیس واقعی** + شبیه‌سازیِ ۳۲ سناریو در تست‌های Django.
> **هیچ خطی از کدِ تولیدی تغییر نکرد.** مصنوعات این بررسی:
>
> | فایل | نقش |
> |---|---|
> | `scripts/entezar_action_audit.py` | ممیزی عددیِ فقط-خواندنی روی دیتابیس واقعی (کپی موقت در /tmp) |
> | `tests/test_entezar_action_simulation.py` | ۳۲ سناریوی شبیه‌سازی (فرم، انتظار، پیامک، باگ missed) |
> | `docs/entezar_action_fields_report.md` | همین سند |
> | `docs/entezar_action_fields_report.html` | نسخهٔ بصریِ تک‌فایل (Tailwind CDN، RTL، نمودار و خط زمانی) |
>
> تاریخ اجرای ممیزی: ۲۰۲۶-۱۰-۲۲ (۱۴۰۵/۰۷/۳۰) — دیتابیس
> `data9/khatibi92669266_backups_db_backup_20261021_223009.sqlite3`.
---
## ۰. پاسخ کوتاه (Executive Summary)
| پرسش | پاسخ |
|---|---|
| فیلد «تاریخ و ساعتی که کار باید انجام شود» چیست؟ | `Action.actionDateTime` — «زمانِ انجام/تماس» وظیفه. برای «بازدید» = زمان قرار بازدید؛ برای «هماهنگی» = **زمان تماس**؛ برای بقیه = زمان انجام وظیفه. |
| فیلد «تا تاریخ پیگیری نشود» چیست؟ | `Customer.visit_hold_until` (پایانِ روز) — فقط برای «هماهنگی» ذخیره می‌شود؛ قرارِ بعدیِ مشتری. |
| اثر فیلد اول کجاست؟ | ۸ مصرف‌کننده: لیست وظایف پنل، «آخرین بازدید» و دورهٔ بازدید، لنگر چرخهٔ پیامک، قرار «بازدید برنامه‌ریزی‌شده»، قرار «هماهنگی»، فهرست پیگیری یک روز، فهرست «عقب‌افتاده‌ها»، و متن پیامک «ثبت بازدید». |
| اثر فیلد دوم کجاست؟ | ۳ مصرف‌کننده: ستون/برچسب «قرار دستی» در صفحهٔ انتظار، فهرست پیگیری روزِ همان قرار، و **مسدودسازی یادآوری دوره‌ای «بازدید» (SMS)** تا پایان آن روز. |
| روی SMS چه اثری دارد؟ | `visit` (دوره‌ای) با هر سه منبعِ قرار (دستی، بازدید برنامه‌ریزی‌شده، هماهنگی آینده) مسدود می‌شود؛ `visit_created` (رویدادی) **فقط** با ثبت اقدام «بازدید» ارسال می‌شود و از `actionDateTime` مقدار `{date}` می‌گیرد — فیلد تعویق روی آن هیچ اثری ندارد. |
| تیک «این کار انجام شده است» در هماهنگی؟ | تیک‌دار ⇒ `status=finish` + `finishDateTime` (خالی ⇒ زمان ثبت) + اگر تاریخ تعویق خالی باشد **پیش‌فرض ۱۴ روز** قرار. بدون تیک ⇒ `waite` و بدون پیش‌فرض؛ فقط اگر تاریخ تعویق پر شود قرار ثبت می‌شود. |
| چرا `missed=1` پر از مشتریِ بازدیدشده است؟ | چون `overdue_followups` **فقط بازدیدِ `waite` (در انتظار)** را حذف می‌کند و بازدیدِ `finish` را هرگز نمی‌بیند؛ همچنین ستون «تا تاریخ پیگیری نشود» را هم نمی‌بیند. |
| «۲ قرار» یعنی چه؟ | برچسب `source_count > 1` — مشتری هم‌زمان بیش از یک **منبعِ** قرار دارد (دستی/بازدید برنامه‌ریزی‌شده/هماهنگی)، نه لزوماً دو قرارِ متفاوت. |
| «قرار دستی» یعنی چه؟ | همان `Customer.visit_hold_until`؛ از فیلد «تا تاریخ پیگیری نشود» نوشته می‌شود و فقط وقتی معتبر است که آخرین فعالیتِ مشتری یک «هماهنگیِ فعال» باشد. |
---
## ۱. نقشهٔ داده و مسیر ذخیره‌سازی
### ۱.۱ ستون‌های دخیل
| موجودیت.ستون | نوع | نویسنده | خواننده‌ها |
|---|---|---|---|
| `Action.actionDateTime` | `DateTimeField(null=True)` | `dateTimeAnjam` فرم (`views/actions.py:90,151,209`) | ۸ مصرف‌کننده — بخش ۲ |
| `Action.finishDateTime` | `DateTimeField(null=True)` | `is_done` + `FinishDateTime` (خالی ⇒ `now`) | «تاریخ مؤثر بازدید»، فهرست پیگیری، تصمیم «انجام‌شده» |
| `Action.status` | `waite/finish/cancell` | تیک «انجام شد» | تمام تصمیم‌ها؛ `cancell` = لغو/حذف نرم |
| `Action.createdDT` | `auto_now_add` | سیستم | قاعدهٔ «آخرین فعالیت» در `_valid_manual_holds` |
| `Customer.visit_hold_until` | `DateTimeField(null=True)` | `visitHoldUntil` هماهنگی (`_apply_visit_hold`) | ۳ مصرف‌کننده — بخش ۳ |
| `Customer.visit_hold_set_at` | `DateTimeField(null=True)` | همان لحظهٔ ثبت (حسابرسی) | فقط نمایش/دیباگ |
| `Customer.last_bazdid_dt` | `DateTimeField(null=True)` | `refresh_last_bazdid` (مشتق) | داشبورد/گزارش‌های قدیمی |
### ۱.۲ مسیر فرم تا دیتابیس
```
customerActionInc.html
dateTimeAnjam ─────────────► _parse_form_datetime(ms) ─► payload['actionDateTime'] ─► Action.actionDateTime
visitHoldUntil ────────────► _parse_form_datetime(ms) ─► payload['holdRaw']
checkIsDone ───────────────► payload['status'] = finish | waite
FinishDateTime ────────────► normalize_finish_datetime (خالی ⇒ now)
│
└─ اگر نوع = هماهنگی:
holdRaw → normalize_hold_until (گذشته ✗، >۱۸۳ روز ✗) → set_visit_hold (فقط رو به جلو)
خالی + finish → default_hold_until() = +۱۴ روز
└─ اگر نوع ≠ هماهنگی و ایجاد تازه:
clear_visit_hold(customer)
```
نکتهٔ کلیدی: `visitHoldUntil` **فقط** زمانی نوشته می‌شود که `actionType == 'hamahangi'`
باشد (`views/actions.py:118-128`). برای «بازدید» و بقیهٔ انواع، حتی اگر مقدار بفرستید،
نادیده گرفته می‌شود (تست: `test_hold_until_ignored_for_visit_actions`).
---
## ۲. فیلد «تاریخ و ساعتی که کار باید انجام شود» — `actionDateTime`
### ۲.۱ یک فیلد، سه معنا (بسته به نوع اقدام)
| نوع اقدام | معنی `actionDateTime` | مرز زمانی در تصمیم‌ها |
|---|---|---|
| `bazdid` + `waite` | زمانِ **قرار بازدید** | تا **پایان روز** (`whole_day=True`) |
| `bazdid` + `finish` | زمانِ **انجام بازدید** | اگر گذشته باشد، «تاریخ مؤثر بازدید» |
| `hamahangi` (هر دو وضعیت) | زمانِ **تماس/هماهنگی** | **لحظه‌ای** (`whole_day=False`) — نه پایان روز |
| `nasb` / `vosol` / `inCall` / `outCall` | زمانِ انجام وظیفه | فقط لیست وظایف |
این تفکیک در `visit_policy._future_actions` (خط ۳۴۱-۳۶۱) صریح مستند شده است: در
هماهنگی، `actionDateTime` «زمان تماس» است، پس «تماسِ امروز» نباید مشتری را تا آخر
امروز پنهان کند؛ در بازدید برنامه‌ریزی‌شده برعکس، قرارِ امروز باید تا پایان امروز معتبر بماند.
### ۲.۲ جدول مصرف‌کننده‌ها
| # | مصرف‌کننده | فایل:تابع | چه می‌کند |
|---|---|---|---|
| ۱ | لیست وظایف امروز/عقب‌افتادهٔ کاربر | `views/actions.py:276-308` (`ActionListToday`, `ActionListUserToday`) | `actionDateTime` بین ۰۰:۰۰ تا ۲۳:۵۹ امروز + `status=waite` ⇒ «وظایف امروز»؛ `< شروع امروز` ⇒ «وظایف عقب‌افتاده» |
| ۲ | «آخرین بازدید» واقعی | `visit_policy.last_visit_map` (۱۴۳) و `effective_visit_dt` (۹۰) | فقط `bazdid+finish`؛ تاریخ مؤثر = `actionDateTime` اگر از حالا جلوتر نباشد، وگرنه `finishDateTime` |
| ۳ | لنگر چرخهٔ بازدید و پیامک | `visit_policy.cycle_anchor_map` (۲۱۴) → `sms/scanner.scan_visit` (۲۱۸-۲۴۵) | آخرین بازدید مؤثر (یا تاریخ نصب) + دورهٔ ۹ ماه ⇒ موعد یادآوری |
| ۴ | قرار «بازدید برنامه‌ریزی‌شده» | `scheduled_visits_map` (۳۸۰) — `bazdid+waite` با `actionDateTime ≥ آغاز امروز` | مشتری از «تعیین‌تکلیف‌نشده» خارج و از یادآوری SMS معاف می‌شود |
| ۵ | قرار «هماهنگی» | `coordination_holds_map` (۳۹۱) — `hamahangi` با `actionDateTime ≥ now` | همان اثر، ولی مرز **لحظه‌ای** |
| ۶ | فهرست پیگیری یک روز | `followups_on_date` (۵۲۲) | `hamahangi` با تاریخ همان روز (یا `finishDateTime` اگر `actionDateTime` خالی باشد) + قرار دستی همان روز |
| ۷ | فهرست «عقب‌افتاده‌ها» | `overdue_followups` (۵۹۰) | **آخرین** `hamahangi` با تاریخ قبل از امروز ⇒ «موعد ازدست‌رفته» (منشأ باگ — بخش ۶) |
| ۸ | متن پیامک «ثبت بازدید» | `sms/triggers.notify_visit_scheduled` (۵۲-۶۱) | `{date}` = `action.actionDateTime` (شمسی می‌شود) |
همچنین در قالب‌ها: ستون تاریخ در `customerActionInc.html:117,169`، `customerAction.html:42`
و مودال ویرایش (`ActionJson` → `dateTimePickerAnjam.setDate`).
### ۲.۳ اعتبارسنجی
* `validate_action_payload` (۹۰۳): نوع/وضعیت باید مجاز باشد؛ فقط برای
`bazdid+finish` سقف «۳۰ روز آینده» اعمال می‌شود (`FINISH_MAX_FUTURE_DAYS`).
* برای هماهنگی هیچ سقفی روی `actionDateTime` نیست.
* اگر تاریخ اتمام خالی بماند و تیک «انجام شد» زده شود، `finishDateTime = now`.
---
## ۳. فیلد «تا تاریخ پیگیری نشود» — `Customer.visit_hold_until`
### ۳.۱ قواعد نوشتن
| قاعده | پیاده‌سازی | اثر |
|---|---|---|
| فقط هماهنگی | `_apply_visit_hold` (`actions.py:101-128`) | برای بازدید/نصب/وصول بی‌اثر |
| پایانِ روز | `end_of_local_day` (۷۰۳) | «تا آخر ۲۳:۵۹:۵۹ همان روز» |
| خالی + تیک «انجام شد» | `default_hold_until()` با `HOLD_DEFAULT_DAYS=14` (۶۷۹، ۷۲۳) | ۱۴ روز تعویق خودکار |
| خالی + بدون تیک | هیچ نوشتنی | مشتری در فهرست می‌ماند |
| تاریخ گذشته | `normalize_hold_until` (۷۶۵) خطا | فرم با پیام فارسی رد می‌شود |
| بیش از ۱۸۳ روز | `HOLD_MAX_DAYS=183` (۶۸۰) خطا | جلوگیری از تعویق بی‌پایان |
| فقط رو به جلو | `set_visit_hold` (۷۲۹): مقدار فعلی ≥ هدف ⇒ نمی‌نویسد | هماهنگی دومِ کوتاه‌تر قرارِ دورتر را عقب نمی‌کشد |
| ثبت اقدام غیرهماهنگی در حالت ایجاد | `clear_visit_hold` | ستون پاک می‌شود (تست: `test_creating_visit_clears_hold_column`) |
| ویرایش اقدام | عمداً دست نمی‌زند | ویرایش گذشته نباید قرارِ فعلی را پاک کند |
### ۳.۲ قاعدهٔ اعتبار («ستون، مرجع نیست»)
`_valid_manual_holds` (۴۰۵-۴۶۰): یک قرار دستی فقط وقتی معتبر است که
1. برای مشتری یک اقدام `hamahangi` با وضعیت `waite` یا `finish` وجود داشته باشد، و
2. **آخرین فعالیتِ مشتری همان هماهنگی باشد** — اگر بعد از آن، اقدامِ غیرهماهنگی
(بازدید، وصول، تماس، …) با `createdDT` بزرگ‌تر ثبت شده باشد، قرار باطل است.
نتیجهٔ عملی: ستون ممکن است پر باشد ولی «مرده». در دادهٔ واقعی از ۵۴ مقدار پرشده،
۵۱ معتبر و ۳ بی‌اعتبار است (۱ بدون هماهنگی فعال، ۲ به‌خاطر اقدام بعدی).
### ۳.۳ مصرف‌کننده‌ها
| مصرف‌کننده | فایل:تابع | اثر |
|---|---|---|
| منبع `manual` صفحهٔ انتظار | `manual_holds_map` (۴۶۳) → `appointment_sources_map` (۶۴۷) | مشتری از «تعیین‌تکلیف‌نشده» خارج و در `?view=manual` دیده می‌شود |
| فهرست پیگیری روزِ قرار | `followups_on_date` (۵۴۳-۵۵۴) | دقیقاً در همان روز (حتی گذشته) در `?view=today` ظاهر می‌شود |
| مسدودسازی یادآوری دوره‌ای SMS | `next_appointment_map` (۲۹۷) → `scan_visit` (`scanner.py:220,232`) | تا پایان آن روز پیامک `visit` صف نمی‌شود |
| نمایش در جدول | `entezar.html:516-518` | ستون «قرار دستی» + برچسب `قرار دستی` |
| مودال ویرایش | `customerActionInc.html:66,706` (`customer_hold`) | مقدار ذخیره‌شده در فیلد بارگذاری می‌شود |
---
## ۴. اثر دو فیلد بر پیامک‌ها
### ۴.۱ دو نوع پیامکِ بازدید — دو منطق کاملاً متفاوت
| نوع | زمان ارسال | تصمیم‌گیر | داده‌های دخیل |
|---|---|---|---|
| `visit` (دوره‌ای) | اسکنر (`scan_visit`) وقتی موعدِ «آخرین بازدید + ۹ ماه» رسیده باشد | `cycle_anchor_map` + `next_appointment_map` | **هر دو فیلد** (فیلد اول به‌واسطهٔ لنگر، فیلد دوم به‌واسطهٔ قرار) |
| `visit_created` (رویدادی) | بلافاصله پس از ثبت اقدام **بازدید** | `notify_visit_scheduled` | فقط `actionDateTime` (برای متن `{date}`) |
### ۴.۲ ماتریس اثر (نتیجهٔ شبیه‌سازی، همه با `scan_visit`)
| سناریو | یادآوری `visit` | پیامک `visit_created` |
|---|---|---|
| مشتری عقب‌افتاده، هیچ اقدامی | ✅ ارسال | — |
| «تا تاریخ پیگیری نشود» = +۱۰ روز (با هماهنگی فعال) | ⛔ تا پایان روزِ دهم؛ از فردای آن ✅ | — |
| ستون `visit_hold_until` پر، ولی **بدون** اقدام هماهنگی | ✅ ارسال (ستون بی‌اثر) | — |
| بازدید `waite` با تاریخ +۵ روز | ⛔ (تا پایان آن روز) | — |
| هماهنگی با تاریخ +۵ روز (بدون تعویق) | ⛔ | — |
| هماهنگی «انجام‌شده» با تاریخ گذشته و بدون تعویق | ✅ ارسال | — |
| ثبت اقدام بازدید (هر وضعیتی) | — | ✅ با `{date}` = `actionDateTime` |
| ثبت اقدام هماهنگی | — | ❌ |
| قرار دستی فعال + ثبت بازدید | — | ✅ (فیلد دوم مانع نیست) |
تست‌های متناظر: `SmsSimulationTests` در `tests/test_entezar_action_simulation.py`
(`test_hold_until_suppresses_visit_reminder`، `test_hold_column_without_coordination_is_inert`،
`test_scheduled_future_visit_suppresses_reminder`، `test_future_coordination_suppresses_reminder`،
`test_visit_action_sends_visit_created_with_action_datetime`، …).
### ۴.۳ چرا «تعویق» روی `visit_created` اثر ندارد؟
چون این پیامک در `views/actions.py:170-171` فقط برای `actionType == bazdid` صدا زده
می‌شود، و اصلاً از `next_appointment_map` عبور نمی‌کند. یعنی اگر هماهنگی بگذارید و بعد
بازدیدی ثبت کنید، پیامکِ «بازدید شما برای تاریخ … ثبت شد» می‌رود حتی اگر «تا تاریخ
پیگیری نشود» فعال باشد. (اگر رفتار مطلوب چیز دیگری است، باید در `notify_visit_scheduled`
یا در شرط فراخوانی آن تغییر کند.)
### ۴.۴ جزئیات مهم `scan_visit`
* `next_appointment_map` = **اجتماع** سه منبع (`manual`، `scheduled`، `coordination`)؛
وجود هر کدام ⇒ `continue` (خط ۲۳۲). پس قرار دستی و تاریخ بازدید و تاریخ هماهنگی هر سه
یادآوری را متوقف می‌کنند — این همان «یک جواب برای صفحه و پیامک» است.
* `dedup_key = visit:{customer_id}:{YYYY-MM موعد}` ⇒ هر دوره حداکثر یک پیامک؛ حذف از صف
فقط همان دوره را کنسل می‌کند.
* خط شروع (`scan_since`) و سقف‌ها می‌توانند ارسال موارد قدیمی را متوقف کنند.
* لنگر چرخه **تماس تلفنی را ریست نمی‌کند**؛ فقط بازدیدِ `finish` (یا نصب) لنگر است.
---
## ۵. تیک «این کار انجام شده است» در اقدام هماهنگی
مسیر تصمیم (`_action_payload_from_request` + `_apply_visit_hold`):
| وضعیت فرم | `Action.status` | `finishDateTime` | `visit_hold_until` | اثر بر فهرست انتظار | اثر بر SMS |
|---|---|---|---|---|---|
| تیک + تاریخ تعویق پر | `finish` | اگر خالی ⇒ `now` | همان تاریخ (پایان روز، فقط رو به جلو) | مشتری تا آن روز پنهان؛ همان روز در `?view=today` برمی‌گردد | یادآوری `visit` تا آن روز قطع |
| تیک + تاریخ تعویق خالی | `finish` | `now` | **+۱۴ روز** | همان | همان |
| بدون تیک + تاریخ تعویق پر | `waite` | `NULL` | همان تاریخ | همان | همان |
| بدون تیک + تاریخ خالی | `waite` | `NULL` | بدون تغییر | مشتری در فهرست می‌ماند | بدون اثر |
نکته‌های ریز:
* در UI، برای هماهنگیِ تیک‌خورده پیش از submit یک **تأیید دو مرحله‌ای** نمایش داده می‌شود
(`holdDoneWarning` / `holdConfirmYes` / `holdConfirmNo` در `customerActionInc.html:609-665`).
اگر کاربر «نه» را بزند، تیک به‌صورت سمت‌کلاینت برداشته می‌شود ⇒ همان سطر سوم/چهارم جدول.
* «انجام شد» هیچ اثر مستقیمی روی «آخرین بازدید» (`last_bazdid_dt`) ندارد؛ هماهنگی هرگز
وارد `last_visit_map` نمی‌شود (`HamahangiIsolationTests`).
* «انجام شد» بدون تاریخ تعویق، **همیشه** ۱۴ روز تعویق می‌سازد؛ اگر کارشناس فقط می‌خواست
تماس را لاگ کند، این پیش‌فرض ناخواسته مشتری را دو هفته پنهان می‌کند.
* در حالت **ویرایش**، `_apply_visit_hold` قرار را پاک نمی‌کند؛ حتی تغییر نوع اقدام از
هماهنگی به بازدید، ستون را دست‌نخورده می‌گذارد (ولی قاعدهٔ «آخرین فعالیت» می‌تواند
آن را بی‌اعتبار کند).
---
## ۶. صفحهٔ `/panel/entezar/` — نمای `?view=today`
### ۶.۱ مسیر محاسبه (گام‌به‌گام)
`views/customers.py:entezar` (۳۷۲-۸۵۳):
1. `now`, `due_cut = visit_due_at(now)` (امروز − ۹ ماه)، `period_months`.
2. `visits = last_visit_map()` — منبع «آخرین بازدید».
3. `sources = appointment_sources_map()` — سه منبع قرارِ **آینده**:
`manual` (ستون تعویق معتبر)، `scheduled` (`bazdid+waite` با تاریخ ≥ آغاز امروز)،
`coordination` (`hamahangi` با تاریخ ≥ همین لحظه).
4. پایهٔ نمای «عقب‌افتاده» (سایر `view`ها): مشتریان `laghv_bazdid=False`، نام‌دار،
`installDate ≤ due_cut`، و سپس `stale_ids` = کسانی که «بازدید معتبر ندارند یا بازدیدشان
≤ `due_cut` است».
5. نمای `today` **جدا** است: `today_followups = followups_on_date(today)` — یعنی
«قرار دستیِ همان روز» + «هماهنگی با تاریخ همان روز»؛ منهای مشتریانی که بازدیدِ
**در انتظار** از آغاز امروز دارند.
6. `_followup_ids`: فیلتر `is_active`، `laghv_bazdid=False`، نام غیرتهی + جست‌وجو؛
مرتب‌سازی بر اساس زودترین موعد.
7. `appointment_rows` برای هر ردیف: ادغام `sources[cid]` با `followups[cid]`، سپس
`source_count` = تعداد کلیدهای موجود از میان `manual/scheduled/coordination`.
8. شمارنده‌ها: `counts['followup_today']` و `counts['followup_overdue']` از همان مسیر
فهرست ⇒ «عدد = تعداد ردیف».
9. عارضهٔ جانبی: ویو در هر GET مقدار `CacheTable('9mahbazdid')` را با
`counts['uncoordinated']` بازنویسی می‌کند (خط ۶۹۹-۷۰۵) — یعنی یک صفحهٔ خواندنی،
نوشتن در دیتابیس دارد.
### ۶.۲ ستون «وضعیت قرار» — معنی برچسب‌ها
قالب `entezar.html:502-515`:
```django
{% if appt.source_count > 1 %} <span>{{ appt.source_count }} قرار</span> ← «۲ قرار»
{% elif appt.manual %} قرار دستی
{% elif appt.scheduled %} بازدید برنامه
{% elif appt.coordination %} هماهنگی
{% else %} بدون قرار
```
* **«N قرار»** = تعداد **منابعِ** قرار، نه تعداد قرارهای واقعی. سه منبع مستقل‌اند و
می‌توانند هم‌زمان وجود داشته باشند.
* «قرار دستی» = ستون `visit_hold_until` (فیلد «تا تاریخ پیگیری نشود»).
* «بازدید برنامه» = اقدام `bazdid` با وضعیت `waite` و تاریخ ≥ آغاز امروز.
* «هماهنگی» = اقدام `hamahangi` (وضعیت `waite` یا `finish`) با تاریخ ≥ همین لحظه.
* ترتیب اولویت برچسب: چندقراره ← دستی ← بازدید ← هماهنگی ← بدون قرار. (ستون
`sort=status` هم همین ترتیب را دارد: 0 بدون، 1 دستی، 2 بازدید، 3 هماهنگی، 4 چندقراره.)
### ۶.۳ شواهد واقعی «۲ قرار»
اعداد زیر روی **کل مشتریان فعالی** که در لحظهٔ ممیزی حداقل یک منبع قرار آینده دارند
محاسبه شده است (نه فقط ردیف‌های یک نما):
| سنجه | مقدار |
|---|---|
| مشتریان با ۱ منبع | ۲٫۲۶۷ |
| مشتریان با ۲ منبع | ۱۶ |
| ترکیب `manual + coordination` | ۱۲ (۱۱ مورد تاریخ‌های **متفاوت**، ۱ مورد هم‌روز) |
| ترکیب `scheduled + coordination` | ۴ |
| بیش از ۲ منبع | ۰ |
نمونه‌ها: `#833` دستی ۲۰۲۶-۱۱-۰۴ / هماهنگی ۲۰۲۶-۱۲-۳۰ — `#2888` هر دو ۲۰۲۶-۱۱-۱۱
(همان یک هماهنگی هم «هماهنگی» شمرده شده هم «قرار دستی»).
**پیامد:** «۲ قرار» همیشه یعنی «دو توافق» نیست. یک هماهنگیِ انجام‌شده که تاریخ تعویق
همان روز را دارد، دو منبع تولید می‌کند و برچسب «۲ قرار» می‌گیرد. اگر هدف نمایش
«تعداد قرارهای واقعی» است، باید منابعِ هم‌منشأ (هماهنگی + قرار دستیِ حاصل از همان اقدام)
یک قرار شمرده شوند.
### ۶.۴ «قرار دستی» — تعریف دقیق
| جنبه | مقدار |
|---|---|
| منبع داده | `Customer.visit_hold_until` (پایانِ روز) |
| نویسنده | فقط اقدام `hamahangi` (فیلد «تا تاریخ پیگیری نشود» / پیش‌فرض ۱۴ روز) |
| شرط اعتبار | یک `hamahangi` فعال + «آخرین فعالیت همان هماهنگی» (`_valid_manual_holds`) |
| شرط نمایش در `?view=today` | `visit_hold_until` داخل بازهٔ همان روز |
| شرط نمایش در `sources` | `visit_hold_until > now` (یعنی قرارِ آینده) |
| اثر | خروج از «تعیین‌تکلیف‌نشده»، ورود به فیلتر `manual`، قطع یادآوری SMS تا آن روز |
عدد واقعی: ۵۴ مشتری ستون پر دارند (۵۱ معتبر)، فقط ۴ ردیف در نمای امروز با منبع
`manual` دیده می‌شوند (۲ فقط-دستی، ۲ دستی+هماهنگی).
---
## ۷. باگ `?view=today&missed=1` — چرا این فهرست پر از مشتریِ بازدیدشده بود؟
> این باگ **رفع شد** (بخش ۹): فهرست از ۱۱۲ ردیف به ۴۲ ردیف رسید و هیچ ردیف
> متناقضی (قرار معتبر + عقب‌افتاده) باقی نماند. متن زیر تحلیل زمان کشف است.
### ۷.۱ تعریف فعلی «عقب‌افتاده» در کد
`visit_policy.overdue_followups` (۵۹۰-۶۴۴):
```python
# آخرین موعد هماهنگی هر مشتری (فقط اقدام hamahangi با waite/finish)
when = as_aware(action_dt) or as_aware(finish_dt) # ← finish هم با actionDateTime
latest[cid] = max(روز when)
overdue = {cid: day for ... if day < today} # ← موعد در گذشته
# تنها حذف‌کننده: بازدیدهای «در انتظار» از زودترین موعد به بعد
pending_visits = _pending_visit_customer_ids(since=آغاز روزِ زودترین موعد)
```
و `_pending_visit_customer_ids` (۴۹۹-۵۱۹) فقط:
```python
actionType='bazdid', status='waite'
```
### ۷.۲ سه ریشهٔ خطا (هم‌راستا با هم)
| # | ریشه | چرا باعث حضور مشتریِ «انجام‌شده» می‌شود |
|---|---|---|
| R1 | **بازدیدِ `finish` هرگز دیده نمی‌شود** | قرار بازدید فقط با `waite` تعریف شده؛ بازدیدی که انجام و بسته شده (`finish`) هیچ اثری در حذف ندارد |
| R2 | **ستون «تا تاریخ پیگیری نشود» نادیده گرفته می‌شود** | `overdue_followups` اصلاً `visit_hold_until` را نمی‌خواند، پس مشتری‌ای که قرارِ آیندهٔ معتبر دارد هم در فهرست «عقب‌افتاده» می‌ماند (و در شمارندهٔ قرمز هم شمرده می‌شود) |
| R3 | **`finish` هم با `actionDateTime` سنجیده می‌شود** | برای هماهنگیِ انجام‌شده، `actionDateTime` «زمان تماس» است نه «قرار بعدی» — همان چیزی که `_future_actions` با `whole_day=False` مستند کرده. نتیجه: هر تماسِ تمام‌شده تا ابد «عقب‌افتاده» می‌ماند تا هماهنگی تازه‌ای ثبت شود |
قاعدهٔ موجود فقط وقتی کار می‌کند که کارشناس **بازدیدِ در انتظار** ثبت کرده باشد؛ یعنی
سناریوی واقعیِ «تماس گرفتم و فردا بازدید می‌کنم» درست عمل می‌کند، اما «تماس گرفتم،
بازدید انجام شد و ثبت شد» هرگز از فهرست خارج نمی‌شود.
### ۷.۳ اعداد واقعی (کپی دیتابیس ۲۰۲۶-۱۰-۲۱)
`?view=today&missed=1` = **۱۱۲ ردیف**:
| سنجه | تعداد | درصد |
|---|---|---|
| حداقل یک بازدیدِ `finish` دارند | ۹۹ | ۸۸٫۴٪ |
| بازدید **پس از ثبت هماهنگی** (createdDT بزرگ‌تر) | ۳۲ | ۲۸٫۶٪ |
| آخرین بازدید **بعد از موعد هماهنگی** | ۲۴ | ۲۱٫۴٪ |
| هیچ بازدیدی ندارند (واقعاً نیازمند پیگیری) | ۱۳ | ۱۱٫۶٪ |
| آخرین هماهنگی `finish` (تماس انجام شده) | ۹۹ | ۸۸٫۴٪ |
| آخرین هماهنگی `waite` | ۱۳ | ۱۱٫۶٪ |
| «قرار دستی» **معتبرِ آینده** دارند و باز هم در فهرست‌اند | ۳۸ | ۳۳٫۹٪ |
| `finish` بدون قرار دستیِ فعال | ۶۱ | ۵۴٫۵٪ |
| بازدیدِ در انتظار (هر تاریخی) دارند | ۰ | ۰٪ |
### ۷.۴ دو نمونهٔ واقعی (خط زمانی کامل)
**#236 — خانم مرضیه جمالی** (`--customer 236`):
```
#77042 hamahangi finish action=2026-10-17 14:31 finish=2026-10-17 15:26 created=2026-10-15
#77176 bazdid finish action=2026-10-18 15:26 finish=2026-10-19 16:12 created=2026-10-17
```
هماهنگی ۱۵ اکتبر ثبت شده، تماس ۱۷ اکتبر انجام شده، **بازدید ۱۸ اکتبر ثبت شده** — ولی
مشتری هنوز در `missed` است، چون R1 و R3 (تاریخِ هماهنگی گذشته است و بازدید `finish`
نادیده گرفته می‌شود).
**#1431 — آقای حمید جمالی:**
```
#63749 hamahangi finish action=2026-10-16 finish=2026-10-17 created=2026-02-09
#77122 bazdid finish action=2026-10-18 created=2026-10-17
#77262 bazdid finish action=2026-10-20 created=2026-10-18
```
دو بازدیدِ انجام‌شده بعد از تماس؛ باز هم در `missed`.
### ۷.۵ چرا «بازدید انجام شده» در ستون «آخرین بازدید» دیده می‌شود ولی چیزی را عوض نمی‌کند؟
ستون «آخرین بازدید» از `last_visit_map` می‌آید (که `bazdid+finish` را می‌بیند)، اما
تصمیم حضور در فهرست از `overdue_followups` می‌آید (که آن را نمی‌بیند). یعنی **دو
تعریف واگرا**؛ دقیقاً همان چیزی که خود `visit_policy` در docstring ابتدایی می‌خواست از
بین ببرد.
### ۷.۶ نمای `today` هم آلوده است (به شکل دیگری)
`?view=today` = **۱۳ ردیف**:
| سنجه | مقدار |
|---|---|
| فقط `coordination` | ۹ |
| فقط `manual` | ۲ |
| `coordination + manual` | ۲ |
| هماهنگی‌های امروز با وضعیت `finish` (تماس انجام‌شده) | ۱ |
| هماهنگی‌های امروز با وضعیت `waite` | ۸ |
| ردیف‌هایی که قرار دستیِ آینده هم دارند (بعداً دوباره می‌آیند) | ۴ |
| ردیف‌هایی که آخرین بازدیدشان در/بعد از همان روز موعد است | ۰ |
> «وضعیت» در دو سطر بالا، وضعیت **آخرین اقدام هماهنگی** همان مشتری است (ممکن است با
> اقدامی که منبع `coordination` امروز را ساخته یکی نباشد).
نتیجه: این نما «موعدِ تماس» را با «موعدِ پیگیری» مخلوط می‌کند؛ تماسِ انجام‌شدهٔ امروز هم
یک ردیف می‌سازد، و اگر همان تماس قرار دستی هم داده باشد، مشتری دو بار (امروز و روزِ
قرار) در فهرست‌های کاری ظاهر می‌شود. همچنین `_pending_visit_customer_ids` فقط
`waite` را حذف می‌کند، پس **بازدیدِ امروزِ انجام‌شده** هم ردیف امروز را حذف نمی‌کند.
### ۷.۷ تناقض آشکار: یک مشتری هم‌زمان «قرار دارد» و «عقب‌افتاده» است
۳۸ مشتری از ۱۱۲ ردیف، در `appointment_sources_map` (و در فیلتر `?view=manual`) قرار
معتبر دارند — یعنی از «تعیین‌تکلیف‌نشده» خارج‌اند — ولی همان لحظه در `missed=1` و در
شمارندهٔ قرمز دیده می‌شوند. مثال: `#3339` قرار دستی ۲۰۲۶-۱۰-۲۲ (امروز) دارد و در
`sources` هم منبع هماهنگی ۲۰۲۶-۱۲-۱۶ دارد؛ یعنی «۲ قرار» گرفته، در حالی که وضعیت
پیگیری‌اش متناقض نمایش داده می‌شود.
---
## ۸. جمع‌بندی ریشه‌ای و پیشنهاد رفع
> وضعیت: **اعمال شد** — فهرست نهایی رفع‌ها، کد تغییرکرده و اثر عددی در بخش ۹.
### ۸.۱ دو تعریف واگرا از «تعیین‌تکلیف شده»
| لایه | معیار فعلی |
|---|---|
| «تعیین‌تکلیف‌نشده» (کارت‌ها/فیلترها) | اجتماع سه منبع قرارِ آینده + اعتبار قرار دستی |
| «عقب‌افتاده» (`missed`) | فقط `hamahangi` + فقط بازدیدِ `waite` |
رفع باگ = هم‌ترازکردن لایهٔ دوم با لایهٔ اول.
### ۸.۲ حداقل تغییرِ ممکن (پیشنهاد، بدون اعمال)
۱. **در `overdue_followups`** بعد از حذف `pending_visits`:
```python
# الف) هر بازدیدِ انجام‌شده که تاریخ مؤثرش از روزِ موعد عقب‌تر است، تکلیف را روشن کرده
for customer_id, action_dt, finish_dt in Action.objects.filter(
actionType=Action.ActionType.BAZDID, status=Action.Status.FINISH,
).values_list('customer_id', 'actionDateTime', 'finishDateTime').iterator():
eff = as_aware(action_dt) if action_dt and as_aware(action_dt) <= now else as_aware(finish_dt)
if eff and eff.date() >= overdue.get(customer_id, today):
overdue.pop(customer_id, None)
# ب) قرار دستیِ معتبر همان چیزی است که کاربر صریحاً گفته «تا این تاریخ پیگیری نکن»
for customer_id in manual_holds_map(now=now):
overdue.pop(customer_id, None)
```
۲. **در `followups_on_date`**: همان قاعدهٔ «بازدید انجام‌شدهٔ بعد از موعد» را هم اعمال
کنید (فیلتر سوم بعد از `pending_visits`)، و اگر مشتری هم `manual` دارد و هم
`coordination` از **همان اقدام**، فقط `manual` را نگه دارید تا ردیف تکراری/«۲ قرار»
کاذب ساخته نشود.
۳. **معنای تاریخ `finish` در هماهنگی**: تصمیم محصولی لازم است؛ پیشنهاد: موعدِ
`finish` را `visit_hold_until` بدانید و `actionDateTime` را فقط برای `waite`
به‌عنوان موعد بشمارید. این تنها راه حذف ۹۹ ردیفِ «تماس انجام‌شده» از فهرست
عقب‌افتاده‌ها بدون از دست دادن قرارهای واقعی است.
۴. **`_valid_manual_holds`**: ابطال قرار را به اقدام‌های «واقعاً جایگزین قرار» محدود کنید
(`bazdid`/`nasb`) نه هر اقدام غیرهماهنگی؛ الان یک سند وصول یا تماس ورودی، قرار را
باطل می‌کند.
۵. **برچسب «N قرار»**: تعداد را بر اساس **تاریخ‌های یکتا** یا **اقدام‌های یکتا** حساب
کنید (نه تعداد کلیدهای منابع)، یا منابع هم‌منشأ را در `appointment_rows` ادغام کنید.
۶. **نوشتن در GET**: بازنویسی `CacheTable('9mahbazdid')` را از ویو به یک دستور/سیگنال
منتقل کنید (کش باید مشتق باشد، نه منبع).
### ۸.۳ ریسک‌ها
* هر تغییر در `overdue_followups` روی شمارندهٔ قرمزِ هدر (`entezarbazdid`/`followup_overdue`)
و هشدارهای صفحه اثر می‌گذارد؛ تست `test_overdue_counter_equals_missed_rows` را حفظ کنید.
* `scan_visit` از `next_appointment_map` می‌خواند، نه از `overdue_followups`؛ پس تغییر
فهرست، پیامک‌ها را جابه‌جا **نمی‌کند** (خوب است).
* تست‌های `MissedViewSimulationTests` رفتار فعلی را قفل کرده‌اند؛ بعد از رفع، باید
`assertIn` به `assertNotIn` تبدیل شود.
---
## ۹. باگ‌های شناسایی‌شده و رفع‌شده
> تاریخ رفع: ۲۰۲۶-۱۰-۲۲ · بدون تغییر در قرارداد داده‌ها (بدون migration) ·
> ۲۶۹ + ۱۹۹ تست قبلی سبز ماند + ۴۶ تست شبیه‌سازی/رگرسیون.
### ۹.۱ چرخهٔ کسب‌وکار (مبنا همهٔ تصمیم‌ها)
```
نصب دستگاه ──► ۹ ماه ──► «موعد بازدید» (کارت عقب‌افتاده + پیامک visit)
│
▼
تماس کارشناس (hamahangi)
├── انجام نشده (waite) ──► موعد تماس = actionDateTime ──► نمای «امروز»
└── انجام شد (finish) ───► «تا تاریخ پیگیری نشود» ──► پنهان تا آن روز + قطع SMS
│
▼
بازدید انجام شد (bazdid + finish)
──► لنگر چرخه از نو (۹ ماه) + پیامک visit_created
```
اصل حاکم بر همهٔ رفع‌ها: **قرار/بازدیدِ انجام‌شده باید فهرست کار را ببندد، ولی هیچ
مشتریِ بدون قرار نباید بی‌صدا از فهرست کار ناپدید شود.**
### ۹.۲ جدول باگ‌ها
| # | نوع | باگ | ریشه | رفع |
|---|---|---|---|---|
| B1 | کسب‌وکار | فهرست `missed=1` پر از مشتریانی که بازدیدشان انجام شده بود (۹۹ از ۱۱۲) | `overdue_followups` فقط بازدیدِ `waite` را حذف می‌کرد | بازدیدِ `finish` با تاریخ مؤثر ≥ موعد ⇒ حذف از فهرست |
| B2 | کسب‌وکار | ۳۸ ردیف هم‌زمان «قرار معتبر آینده» و «عقب‌افتاده» بودند | ستون `visit_hold_until` در `overdue_followups` خوانده نمی‌شد | قرار دستیِ معتبر ⇒ حذف از فهرست |
| B3 | کسب‌وکار | نمای «امروز» بعد از بازدیدِ همان روز، ردیف را نگه می‌داشت | `followups_on_date` فقط `waite` را حذف می‌کرد | بازدیدِ انجام‌شده از همان روز به بعد ⇒ حذف ردیف (فقط امروز/آینده) |
| B4 | کسب‌وکار | یک سند وصول یا تماس ورودی، «قرار دستی» را بی‌صدا باطل می‌کرد | `_valid_manual_holds` هر اقدام غیرهماهنگی را «جایگزین قرار» می‌گرفت | فقط `bazdid`/`nasb` قرار را باطل می‌کنند (`_SUPERSEDING_TYPES`) |
| B5 | کسب‌وکار | «۲ قرار» برای یک توافق (هماهنگی + تعویقِ همان روز) | `source_count` تعداد منابع بود، نه تعداد قرار | شمارش روزهای یکتا (و همان قاعده در `sort=status`) |
| B6 | فنی | مرز «وظایف امروز» با رشتهٔ naive ساخته می‌شد (وابسته به تایم‌زون/DST) | `strftime('%Y-%m-%d 00:00:00')` در `ActionListToday`/`ActionListUserToday` | `_local_day_range()` صریح و aware با پوشش DST |
| B7 | فنی | هر GET صفحهٔ انتظار، `CacheTable` را می‌نوشت (حتی بدون تغییر) | `save()` بی‌قید در ویو | فقط در صورت تغییر + `update_fields` |
| B8 | فنی (تست) | تست `test_overdue_column_hidden_only_in_followup_view` به ساعت اجرا وابسته بود | هماهنگی «امروز ساعت ۱۲» می‌توانست «آینده» شمرده شود | تست با مشتری بدون قرار، مستقل از ساعت |
### ۹.۳ اثر روی دادهٔ واقعی (همان نسخهٔ پشتیبان)
| سنجه | قبل | بعد |
|---|---|---|
| ردیف‌های `?view=today&missed=1` | **۱۱۲** | **۴۲** |
| ردیف با قرار دستیِ معتبر و هم‌زمان در فهرست | ۳۸ | **۰** |
| ردیف با بازدیدِ بعد از موعد (بسته‌شده) | ۶۲ (۳۲ + ۲۴ + …) | **۰** |
| ردیف با بازدیدِ قدیمی‌تر از موعد (واقعاً نیازمند) | ۳۱ | ۳۱ |
| ردیف بدون هیچ بازدید | ۱۳ | ۱۳ |
نمونه‌های گزارش‌شده: `#236` و `#1431` دیگر در فهرست نیستند (بازدیدشان بعد از موعد
هماهنگی ثبت شده بود).
### ۹.۴ چرا این رفع‌ها چرخه را نمی‌شکنند
* `next_appointment_map` (منبع تصمیم اسکنر پیامک) دست نخورده است ⇒ **هیچ پیامکی
جابه‌جا نشد**؛ فقط نمایش فهرست کار درست شد.
* هیچ فیلد/معنای داده‌ای تغییر نکرد؛ `visit_hold_until` همچنان مشتق و بی‌اعتبار
به‌تنهایی است.
* هر رفع، «حذف از فهرست کار» است نه «حذف مشتری»: مشتریِ بدون قرار همچنان در
`?view=uncoordinated` (تعیین‌تکلیف‌نشده) دیده می‌شود.
* قاعدهٔ «عدد هشدار = تعداد ردیف فهرست» حفظ شد (شمارنده از همان تابع می‌آید).
* `followups_on_date` برای **روزهای گذشته** رفتار قبلی را نگه می‌دارد تا مرور
تقویم، سندِ کاریِ همان روز را بازنویسی نکند.
### ۹.۵ باقی‌مانده (تصمیم محصولی، عمداً تغییر نکرد)
* موعدِ هماهنگیِ `finish` همچنان «زمان تماس» است. اگر تعویق منقضی شود و بازدیدی
ثبت نشود، مشتری دوباره «نیازمند پیگیری» می‌شود. این عمدی است: «ناپدید شدن
بی‌صدای مشتریِ بی‌قرار» خطرناک‌تر از «یک ردیف اضافه» است. اگر کسب‌وکار تصمیم
بگیرد تماسِ انجام‌شده چرخه را ۹ ماه عقب بیندازد، تغییر فقط افزودن `hamahangi`
به `_CYCLE_RESET_TYPES` است — با اثر جانبی روی پیامک‌ها.
* `Customer.visit_hold_until` همچنان ستون مشتق است (بدون migration)؛ تنها
نویسنده‌اش باید `set_visit_hold` بماند.
### ۹.۶ بازخورد شفاف + تأیید کاربر برای «عقب‌بردن» تاریخ قرار
> پیاده‌سازی‌شده · بدون migration · منطق تصمیم‌گیری عوض نشده (قاعدهٔ «فقط رو به جلو» پابرجاست).
پیش از این، اگر تاریخ «تا تاریخ پیگیری نشود» از قرار فعلی عقب‌تر بود،
`set_visit_hold` مقدار `False` برمی‌گرداند و لایهٔ فراخوان آن را دور می‌ریخت:
نه نوشتنی، نه پیامی، نه نشانه‌ای. حالا زنجیره کامل و شفاف است:
| لایه | تغییر |
|---|---|
| `visit_policy.set_visit_hold(..., force=False)` | پارامتر تأیید صریح؛ پیش‌فرض همان قاعدهٔ «فقط رو به جلو» |
| `views/actions._apply_visit_hold(...)` | خروجی ساخت‌یافته: `{'ok', 'reason', 'current', 'target', 'force_allowed'}` — `reason='forward_only'` یعنی نوشته نشد |
| `views/actions._report_hold_result(...)` | `messages.warning` کوتاه: «تاریخ قرار ذخیره نشد؛ از قرار فعلی این مشتری (تاریخ) عقب‌تر است.» + مسیر انجام (تیک/ویرایش) |
| `customerNewAction` / `customerEditAction` | مقدار `force_hold` فرم و گزارش نتیجه به کاربر |
| `customerActionInc.html` | اعلان `holdBackwardNotice` + `force_hold` + تیک «تاریخ قرار را عقب ببر» (فقط در ویرایش) |
| `customerBaseNew.html` | پیام `warning` با رنگ هشدار (کهربایی) نمایش داده می‌شود |
### تجربهٔ کاربر
1. تاریخ عقب‌تر را انتخاب می‌کند؛ همان لحظه (نه فقط هنگام ثبت) اعلان کوتاه باز می‌شود:
«این تاریخ از قرار فعلی عقب‌تر است» / «قرار فعلی: ۱۴۰۵/۰۸/۰۲».
2. **در ویرایش**: تیک «تاریخ قرار را عقب ببر» ⇒ `force_hold=1` و ذخیره می‌شود.
بدون تیک، ثبت بلاک می‌شود و فوکوس روی همان اعلان می‌رود.
3. **در ایجاد وظیفهٔ تازه**: تیک وجود ندارد؛ به‌جای آن یک خط راهنما:
«این تاریخ ذخیره نمی‌شود. برای تغییر قرار، وظیفهٔ هماهنگی فعلی را ویرایش کنید.»
دلیل: در ایجاد، کاربر قصد اصلاح قرار موجود را ندارد و اجازه دادن می‌توانست
ناخواسته قولِ ثبت‌شدهٔ قبلی (شاید کارِ کارشناس دیگر) را کوتاه کند. خودِ وظیفه
ثبت می‌شود؛ فقط تاریخ قرار عقب نمی‌رود.
4. اگر جاوااسکریپت اجرا نشود یا کاربر مستقیم POST بزند، **سرور** هم مستقل بررسی
می‌کند: نه می‌نویسد، نه ساکت می‌ماند — پیام هشدار روی صفحهٔ اقدام نمایش داده می‌شود
(برای ایجاد، پیام «همان وظیفه را ویرایش کنید» را نشان می‌دهد).
5. همان روز ⇒ تغییر واقعی لازم نیست؛ هیچ پیامی هم ساخته نمی‌شود.
### تست‌ها (`HoldBackwardConfirmTests` — ۸ مورد)
| تست | چه چیزی را تضمین می‌کند |
|---|---|
| `test_earlier_date_is_not_saved_and_user_is_told` | نوشته نمی‌شود + پیام شامل «ذخیره نشد»، قرار فعلی و مسیر تأیید؛ پیام در HTML صفحه هم دیده می‌شود |
| `test_user_confirmation_writes_the_earlier_date` | با `force_hold` تاریخ عقب‌تر ذخیره می‌شود |
| `test_forward_date_needs_no_confirmation` | تاریخ جلوتر مثل قبل بدون پرسش ذخیره می‌شود |
| `test_same_day_is_silent` | همان روز ⇒ بدون پیام |
| `test_create_does_not_move_the_hold_backward` | در ایجاد، حتی با `force_hold=1` ستون عقب نمی‌رود، ولی خودِ وظیفه ثبت می‌شود + پیام «ویرایش کنید» |
| `test_apply_visit_hold_reports_reason` | قرارداد تابع: `reason='forward_only'` و `force_allowed=True` در ویرایش |
| `test_apply_visit_hold_ignores_force_on_create` | `force_hold` در ایجاد نادیده گرفته می‌شود |
| `test_modal_exposes_confirmation_controls` | قالب شامل `data-customer-hold`، `force_hold`، `hbForceRow`، `hbCreateHint` است |
علاوه بر آن، منطق جاوااسکریپت با اجرای همان کدِ قالب (نه کپی) در Node آزموده شد:
«عقب‌تر ⇒ هشدار»، «جلوتر/همان روز/بدون قرار/نوع غیرهماهنگی ⇒ بی‌هشدار».
---
## ۱۰. سناریوی «تعویق پیش از تاریخ کار» (۱۴۰۵/۰۸/۰۳ در برابر ۱۴۰۵/۰۸/۰۵)
> بازتولید: `scripts/coordination_hold_order_probe.py --customer 5124`
> قفل رفتاری: `tests/test_entezar_action_simulation.py::HoldBeforeWorkDateSimulationTests`
### ۱۰.۱ چه اتفاقی می‌افتد
| ورودی | مقدار ذخیره‌شده |
|---|---|
| نوع = هماهنگی · «تاریخ و ساعتی که کار باید انجام شود» = ۱۴۰۵/۰۸/۰۵ | `Action.actionDateTime` = ۲۰۲۶-۱۰-۲۷ |
| «تا تاریخ پیگیری نشود» = ۱۴۰۵/۰۸/۰۳ | `Customer.visit_hold_until` = ۲۰۲۶-۱۰-۲۵ ۲۳:۵۹:۵۹ |
هر دو تاریخ در آینده‌اند، پس `normalize_hold_until` قبول می‌کند:
**هیچ اعتبارسنجی‌ای «تعویقِ زودتر از تاریخ کار» را رد نمی‌کند** (نه در فرم، نه در سرور).
نتیجه: دو تاریخ متفاوت برای یک مشتری ⇒ منبع `manual` (۱۰-۲۵) + منبع `coordination` (۱۰-۲۷).
### ۱۰.۲ گاه‌شمار رفتار (شبیه‌سازی‌شده، هم با تیک و هم بدون تیک)
| روز | منابع قرار | برچسب | «نیازمند پیگیری امروز» | «عقب‌افتاده» | یادآوری SMS |
|---|---|---|---|---|---|
| ۱۰-۲۲ تا ۱۰-۲۴ | manual + coordination | **۲ قرار** | خیر | خیر | ارسال نمی‌شود |
| ۱۰-۲۵ (روزِ تعویق) | manual + coordination | ۲ قرار | **بله** ← ردیف کاری اضافه | خیر | ارسال نمی‌شود |
| ۱۰-۲۶ | coordination | ۱ قرار | خیر | خیر | ارسال نمی‌شود |
| ۱۰-۲۷ (تاریخ کار) | coordination | ۱ قرار | **بله** (روز قرار) | خیر | ارسال نمی‌شود |
| ۱۰-۲۸ به بعد | — | بدون قرار | خیر | **بله** | (موعد چرخه) |
مقایسه با حالت سالم (تعویق ۱۱-۰۱ ≥ تاریخ کار ۱۰-۲۷): از ۱۰-۲۸ به بعد
`manual` باقی می‌ماند، «۱ قرار» می‌شود و مشتری «عقب‌افتاده» نمی‌شود.
### ۱۰.۳ اثر تیک «انجام شد»
| مورد | با تیک | بدون تیک |
|---|---|---|
| `status` | `finish` | `waite` |
| `finishDateTime` | اگر خالی ⇒ **همین حالا** (نه ۱۰-۲۷) | `NULL` |
| `visit_hold_until` | همان ۱۰-۲۵ | همان ۱۰-۲۵ |
| پنهان‌سازی/«۲ قرار»/SMS | **یکسان** | **یکسان** |
| «لیست وظایف» و شمارندهٔ داشبورد (`status='waite'`) | خارج می‌شود | می‌ماند (وظیفهٔ ۱۰-۲۷) |
| صف «نظرسنجی» (`finish` و `nazarDT` خالی) | **وارد می‌شود** | وارد نمی‌شود |
| تشخیص «قرار دستی» | معتبر (همهٔ شرط‌ها) | معتبر |
یعنی تیک روی «قرار» اثری ندارد؛ فقط «وظیفه بسته شد» را اعلام می‌کند و در عوض
مشتری را به صف نظرسنجی می‌برد.
### ۱۰.۴ پیامدهای کسب‌وکاری این ورودی
1. **ردیف کاری تکراری**: در ۱۰-۲۵ (روز تعویق) مشتری در «نیازمند پیگیری» ظاهر می‌شود،
در حالی که قرارِ توافق‌شده ۱۰-۲۷ است ⇒ تماسِ بی‌مورد.
2. **برچسب گمراه‌کننده**: «۲ قرار» نشان می‌دهد دو توافق وجود دارد، ولی در واقع یک
توافق است با تعویقی که زودتر از موعدِ کار تمام می‌شود.
3. **سقوطِ یک‌روزه به «عقب‌افتاده»**: فردای تاریخ کار، تعویق قبلاً منقضی شده، پس
«بدون قرار» و «عقب‌افتاده» می‌شود.
4. **بدون ارسال پیامک در فاصلهٔ ۱۰-۲۵ تا ۱۰-۲۷**: چون `next_appointment_map` (که اسکنر
می‌خواند) هنوز منبع `coordination` را دارد؛ صفحه و پیامک واگرا نمی‌شوند.
### ۱۰.۵ توصیه
* برای یک توافق، **تاریخ تعویق = تاریخ کار** (یا دورتر) بگذارید؛ آن‌وقت «۱ قرار» می‌شود
و ردیف تکراری ساخته نمی‌شود.
* اگر می‌خواهید سیستم جلوی این ورودی را بگیرد، حداقل تغییر: در
`normalize_hold_until`/ویو، اگر `hold < actionDateTime` بود، **هشدار** بدهید
(نه خطا) — چون در برخی سناریوها («تعویق تا این تاریخ، بعدش خودم تماس می‌گیرم»)
ممکن است عمدی باشد.
---
## ۱۱. بازتولید و اعتبارسنجی
### ۱۱.۱ ممیزی روی دیتابیس واقعی (فقط خواندن)
```bash
.venv/Scripts/python.exe scripts/entezar_action_audit.py
.venv/Scripts/python.exe scripts/entezar_action_audit.py --samples 10
.venv/Scripts/python.exe scripts/entezar_action_audit.py --customer 236
.venv/Scripts/python.exe scripts/entezar_action_audit.py --db data9/<backup>.sqlite3
```
اسکریپت ابتدا کل فایل sqlite (به‌همراه `-wal`/`-shm`) را در پوشهٔ موقت کپی می‌کند و
همهٔ کوئری‌ها/توابع `visit_policy` را روی همان کپی اجرا می‌کند؛ دیتابیس اصلی دست‌نخورده
می‌ماند. خروجی شامل پنج بخش است (missed، today، برچسب‌ها، ستون قرار، هماهنگی‌ها) و با
`--customer` خط زمانی کامل یک مشتری.
### ۱۱.۲ شبیه‌سازی‌ها
```bash
.venv/Scripts/python.exe manage.py test tests.test_entezar_action_simulation -v 2
```
نتیجهٔ اجرا: **۵۵ تست، OK** (Django 3.2.25، SQLite حافظه‌ای). پوشش:
| کلاس | تعداد | چه چیزی را ثابت می‌کند |
|---|---|---|
| `ActionFormSimulationTests` | ۹ | نگاشت دو فیلد، پیش‌فرض ۱۴ روز، رد تاریخ گذشته/بیش از ۶ ماه، «فقط رو به جلو»، بی‌اثری روی بازدید |
| `EntezarDecisionSimulationTests` | ۸ | «۲ قرار» فقط برای دو روز متفاوت، «قرار دستی»، ابطال با بازدید، روزِ پیگیری، خروج با بازدید آینده |
| `AppointmentLifetimeSimulationTests` | ۴ | سند وصول/تماس قرار را باطل نمی‌کند؛ بازدید/نصب باطل می‌کند (B4) |
| `FollowupDaySimulationTests` | ۳ | بازدیدِ امروز ردیف امروز را می‌بندد؛ بازدید دیروز نه؛ مرور روز گذشته دست‌نخورده (B3) |
| `TaskListBoundaryTests` | ۲ | مرز ۰۰:۳۰/۲۳:۳۰ به وقت محلی؛ وظیفهٔ انجام‌شده در «امروز» نمی‌آید (B6) |
| `CacheTableWriteTests` | ۱ | عدد کش داشبورد = شمارندهٔ «تعیین‌تکلیف‌نشده» (B7) |
| `MissedViewSimulationTests` | ۹ | رفع B1/B2: بازدید `finish` و قرار دستی معتبر از فهرست خارج می‌کنند؛ قرارِ مرده نه |
| `SmsSimulationTests` | ۱۱ | قطع/بازگشت یادآوری دوره‌ای، بی‌اثری ستون تنها، `visit_created` با تاریخ، پاک‌شدن ستون با ثبت بازدید |
| `HoldBeforeWorkDateSimulationTests` | ۹ | سناریوی «تعویق پیش از تاریخ کار»: پذیرش، «۲ قرار»، ردیف تکراری، سکوت SMS، سقوط به missed، تفاوت تیک/بدون‌تیک |
> سناریوی ۱۴۰۵/۰۸/۰۳ در برابر ۱۴۰۵/۰۸/۰۵ در بخش ۱۰ به‌تفصیل آمده و با
> `scripts/coordination_hold_order_probe.py` قابل بازپخش است.
### ۱۱.۳ محدودیت‌ها / فرض‌ها
* همهٔ اعداد از **یک نسخهٔ پشتیبان** (۲۰۲۶-۱۰-۲۱) است؛ با دادهٔ فردا تغییر می‌کنند.
* ممیزی «تاریخ مؤثر» را با قاعدهٔ `effective_visit_dt` بازسازی می‌کند؛ اگر رکوردی
`actionDateTime` آینده داشته باشد، `finishDateTime` مبنا می‌شود.
* «بازدید انجام‌شده پس از هماهنگی» بر اساس `createdDT` (زمان ثبت) سنجیده شده، نه تاریخ
واقعی بازدید؛ چون `createdDT` تنها ستونی است که ترتیب ثبت را تضمین می‌کند.
* هیچ‌کدام از تست‌ها به «امروزِ واقعی» وابسته نیستند مگر از طریق `timezone.now()` در
لحظهٔ اجرا؛ برای همین در هر تاریخ دیگری هم باید سبز بمانند (تاریخ‌ها نسبی‌اند).
### ۱۱.۴ فایل‌های مرتبط
| فایل | نقش |
|---|---|
| `admin_panel/visit_policy.py` | تنها منبع حقیقت قرار/بازدید (`last_visit_map`، `appointment_sources_map`، `followups_on_date`، `overdue_followups`، `manual_holds_map`) |
| `admin_panel/views/actions.py` | ایجاد/ویرایش/حذف اقدام، `_apply_visit_hold`، لیست وظایف |
| `admin_panel/views/customers.py` | ویوی `entezar` و ساخت `appointment_rows` |
| `admin_panel/sms/scanner.py` | `scan_visit` (یادآوری دوره‌ای) |
| `admin_panel/sms/triggers.py` | `notify_visit_scheduled` (پیامک ثبت بازدید) |
| `admin_panel/templates/admin_panel/entezar.html` | برچسب‌های وضعیت و ستون‌های قرار |
| `admin_panel/templates/admin_panel/customerActionInc.html` | فرم و JS دو فیلد + تأیید «هماهنگی انجام شد» |
| `docs/visit_hold_entezar.md` | طراحیِ موردنظر (سند قبلی) — بخش‌هایی از آن در کد فعلی رعایت نشده (R2/R3) |
+9 -2
View File
@@ -43,13 +43,20 @@
>
> ۱. تاریخش از حالا جلوتر باشد،
> ۲. برای همان مشتری یک اقدام **هماهنگیِ فعال** (`waite` یا `finish`) وجود داشته باشد،
> ۳. **آخرین فعالیتِ مشتری همان هماهنگی باشد** — اگر بعد از آن، اقدام دیگری
> (به‌ویژه «بازدید») ثبت شده باشد، قرار منتفی است.
> ۳. **آخرین اقدامِ جایگزین‌کنندهٔ قرار، همان هماهنگی باشد** — اگر بعد از آن
> «بازدید» (`bazdid`) یا «نصب» (`nasb`) ثبت شده باشد، قرار منتفی است.
> اقدام‌های دیگر (وصول، تماس ورودی/خروجی، …) قرار را باطل **نمی‌کنند**.
شرط ۳ برای این است که «ثبت بازدید پس از هماهنگی» قرار را خودکار باطل کند.
بدون آن، قرار قدیمی تا تاریخش زنده می‌ماند و مشتری در فهرست «با قرار» می‌ماند،
هرچند بازدید جای هماهنگی را گرفته است.
> **اصلاح ۲۰۲۶-۱۰-۲۲:** قبلاً «هر اقدام غیرهماهنگی» قرار را باطل می‌کرد؛ نتیجه
> این بود که یک سند وصول یا یک تماس ثبت‌شده، قرارِ توافق‌شده را بی‌صدا از بین
> می‌برد و مشتری دوباره در فهرست تماس ظاهر می‌شد. حالا فقط `bazdid`/`nasb`
> (یعنی اقدام‌هایی که واقعاً جای قرار می‌نشینند) قرار را باطل می‌کنند
> (`visit_policy._SUPERSEDING_TYPES`).
نتیجهٔ این قاعده:
| رویداد | اثر روی قرار |
+64 -48
View File
@@ -1,6 +1,6 @@
{
"0": "views/sms.py",
"1": "run_all",
"1": "AutoSendTests",
"2": "django_db",
"3": "views/store.py",
"4": "ModelsPackageContractTests",
@@ -11,14 +11,14 @@
"9": "views/customers.py",
"10": "django_db_models_deletion",
"11": "PanelTestCase",
"12": "make_customer",
"12": "mark_primary",
"13": "EventTriggerTests",
"14": "views/finance.py",
"15": "django_contrib",
"15": "django_conf",
"16": "sms0098ws.py",
"17": "Admin Panel Customization",
"18": "User",
"19": "apply_timing",
"18": "views/actions.py",
"19": "timing_for",
"20": "InactiveCustomerHiddenTests",
"21": "normalize_mobile",
"22": "Login Template Forms",
@@ -26,12 +26,12 @@
"24": "factories.py",
"25": "Rip'n Fast. Fewer Tokens. Better Code.",
"26": "views/factors.py",
"27": "sms0098.py",
"27": "SMSResult",
"28": "make_user",
"29": "QueueReviewTests",
"30": "NoticeMechanismTests",
"31": "name_fix.py",
"32": "enqueue_sms",
"32": "scanner.py",
"33": "ZeroZeroNinetyEightWebServiceTests",
"34": "ScannerTests",
"35": "admin_panel/templates/admin_panel/baseHesabdari.html",
@@ -44,47 +44,47 @@
"42": "SMS Integration Docs",
"43": "enqueue.py",
"44": "django_shortcuts",
"45": "test_inventory.py",
"45": "stock_in",
"46": "admin_panel/templates/admin_panel/customerAction.html",
"47": "SmsReportTests",
"48": "write_tree",
"48": "generate_snapshot",
"49": "CustomerInfoCardUITests",
"50": "TestCase",
"50": "enqueue_sms",
"51": "Phone Cleanup Guide",
"52": "Customer",
"52": ".run_command",
"53": "project_tree.py",
"54": "Agent Operating Guidelines",
"55": "Command",
"56": "BehinSMSProvider",
"57": "activate_sms",
"57": "run_all",
"58": "Financial Data Objects",
"59": "Hybrid Migration Command",
"60": ".run_cmd",
"60": "PhoneNumber",
"61": "Resource Cleanup Handler",
"62": "SMS Timing Migration",
"63": "admin_panel/templates/admin_panel/bedehi.html",
"64": "customerIDFactorBackup.html",
"65": "bale/providers.py",
"66": "os",
"65": "VisitHoldTests",
"66": "reconciliation_tool.py",
"67": "Graphify Tooling Docs",
"68": "Command",
"69": "ZeroZeroNinetyEightLinkTests",
"70": "SMS Template Seeding",
"71": "admin_panel/entezar.html",
"72": "customerNewBackup.html",
"73": "django_conf",
"73": "SMSProvider",
"74": "0070_sms_types_seven.py",
"75": "Endpoint Smoke Tests",
"75": "ViewSmokeTests",
"76": "Timezone Migration History",
"77": "Command",
"78": "UrlContractTests",
"78": "TodayFollowupTests",
"79": "Brand Scrubbing Migration",
"80": "SMS Dedup Migration",
"81": "Installation Guide",
"82": "Project Honesty Contract",
"83": "Flow Control Migration",
"84": "Permission Label Migration",
"85": "PhoneNumber",
"85": "visit_policy.py",
"86": "Output Format Specs",
"87": "Performance Benchmarks",
"88": "SMS Integration Overview",
@@ -94,25 +94,25 @@
"92": "Infrastructure Services",
"93": "Agent Setup Guide",
"94": "First Use Guide",
"95": "ConsoleEncodingTests",
"96": "test_factors.py",
"95": "console.py",
"96": "test_sms.py",
"97": "تجمیع شماره‌های پیامک مشتریان",
"98": "Command",
"99": "Base Template",
"100": "test_sms.py",
"101": "Command",
"100": "test_customer_name_fix.py",
"101": "Customer",
"102": "WorkerTests",
"103": "manage.py",
"104": "افزودن پیشوند «آقای/خانم» به نام مشتریان",
"105": "Command",
"106": "QueueStatsTests",
"107": "signals.py",
"106": "make_customer",
"107": "admin_panel/apps.py",
"108": "SMS Domain Logic",
"109": "Customer Communication Partials",
"110": "SMS Template Management",
"111": "Documentation and Graphing",
"112": "Admin Migration Package",
"113": "recipients.py",
"113": "resolve_recipients",
"114": "Customer Action Templates",
"115": "Customer Feedback Components",
"116": "Customer Invoice Details",
@@ -152,7 +152,7 @@
"150": "Transfer Processing Endpoint",
"151": "Satisfaction Edit Endpoint",
"152": "Customer Deletion Endpoint",
"153": "HttpProviderRobustnessTests",
"153": "HTTPSMSProvider",
"154": "Django Permissions System",
"155": "Static Asset Management",
"156": "Data Cleanup Documentation",
@@ -163,10 +163,10 @@
"161": "Project Dependencies",
"162": "Test Suite Package",
"163": "Customer",
"164": "factory.py",
"164": "EntezarPageControlsTests",
"165": "0075_cheque_funding_before_due.py",
"166": "توقف و ادامه (Resume / Checkpoint)",
"167": "Command",
"167": "RecalcCommandTests",
"168": "0071_sms_settings_permission.py",
"169": "0056_auto_20260731_1824.py",
"170": "الگوریتم تصمیم (به همین ترتیب)",
@@ -174,36 +174,52 @@
"172": "0073_alter_action_actiontype_choices.py",
"173": "stop.sh",
"174": "0074_alter_action_status_choices.py",
"175": "remove_query_param",
"175": "at",
"176": "اجرای محلی (توسعه)",
"177": ".test_deleted_visit_reminder_rearms_next_period_only",
"177": "apply_timing",
"178": "اجرا روی سرور Production",
"179": "اجرای محلی (توسعه)",
"180": "ادامهٔ job پس از crash / قطع برق",
"181": "اجرا روی سرور Production",
"182": "wsgi.py",
"182": "os",
"183": "0005_auto_20220121_2054.py",
"184": "0009_remove_storeroom_user.py",
"184": "test_visit_policy.py",
"185": "0014_auto_20220123_1348.py",
"186": "0015_auto_20220124_0858.py",
"187": "0017_factoriteam_garantyesh.py",
"188": "0022_article_typepy.py",
"189": "0026_auto_20220209_1924.py",
"186": "ActionSoftDeleteTests",
"187": "last_visit_map",
"188": "CoordinationDoneConfirmTests",
"189": "BaseTemplateStructureTests",
"190": "0028_action_grade.py",
"191": "0032_article_paystatus.py",
"192": "0033_article_nahvepardakht.py",
"191": "validate_action_payload",
"192": "NextAppointmentTests",
"193": "0034_alter_article_sarresid.py",
"194": "0035_alter_article_sarresid.py",
"195": "0040_customer_laghv_bazdid.py",
"194": "django_contrib_auth_models",
"195": "entezar",
"196": "0041_auto_20240216_1607.py",
"197": "0048_alter_productinstore_options.py",
"198": "0053_storeroom_owners.py",
"199": "0055_action_nextnazardt.py",
"197": "Action",
"198": "بخش دوم — امکانات جدید رابط کاربری صفحهٔ انتظار",
"199": "بازمحاسبهٔ «آخرین بازدید» مشتریان",
"200": "0062_sms_link_provider.py",
"201": "0063_alter_smsqueue_status.py",
"202": "0068_auto_20261010_1305.py",
"201": "TestCase",
"202": "newFactor",
"203": "0072_customernamefixjob.py",
"204": "0077_alter_smstemplate_text_balance_help.py",
"205": ".test_visit_period_comes_from_template",
"206": ".test_period_start_filters_out_very_old_customers"
"205": "«قرار بعدی» بازدید و صفحهٔ انتظار — توضیح کامل عملکرد",
"206": "SMSDeliveryCommandTests",
"207": "ActionModalTemplateTests",
"208": "customer_balance",
"209": "VisitDecisionTests",
"210": "EntezarScannerConsistencyTests",
"211": "EntezarLayoutTests",
"212": "signals.py",
"213": "۴. فرم ثبت اقدام (UI)",
"214": "۱.۱ نمای «نیازمند پیگیری امروز» (`?view=today`) — **پیش‌فرض صفحه**",
"215": "cleanup_visit_dedup.py",
"216": "۶.۱ حذف یک اقدام (soft-delete)",
"217": "active_deferral",
"218": "index",
"219": "0025_action_actiontype.py",
"220": "0027_alter_action_nazar.py",
"221": "0029_alter_article_options.py",
"222": "0030_auto_20220414_1047.py"
}
+1 -1
View File
@@ -1 +1 @@
{"0": "1dfdd2cf317807f3", "1": "17cefe4501ef5349", "2": "b498606dcac72b73", "3": "bd6e67d4a5f28a70", "4": "28cdf3baa198be4d", "5": "fc9b619d52672e4e", "6": "50f8029c1ec04918", "7": "97d81e46229f8a49", "8": "313943d53fc2bd9f", "9": "627a6196750653eb", "10": "b61d115e59860e03", "11": "9e67a33b989286ec", "12": "e88164ad40862751", "13": "3b56f304a0c6c939", "14": "6dce744bd0131ce7", "15": "e41d0c7172d175f1", "16": "7d8afbd7fea7cf7b", "17": "9eec9953ee3ca67f", "18": "d947d20b351370eb", "19": "d26d5b0aeca2417c", "20": "a8997e767700eb26", "21": "80296933c90021fc", "22": "9701f1591d8fda6e", "23": "8f0f51ff611eb425", "24": "2fdec3396c6901a3", "25": "75866dc8db907971", "26": "bcc559e489b72153", "27": "9b6b55a8d8dcb4c4", "28": "35722e3c74f50804", "29": "e0f6e325bac28363", "30": "bf1e337abf82f763", "31": "e4282689f6251d51", "32": "e5bd8cd35998e36a", "33": "3746e81c1256465f", "34": "817f04c7b50c9b76", "35": "0928f3aa4e597117", "36": "4e9567703b727de7", "37": "d36df3c85181f806", "38": "bc695fcee2050806", "39": "7b572aa5bf8298b3", "40": "4cc746f3f10c71a6", "41": "59b1c6760dbaf1b9", "42": "bbd8a9066e48dada", "43": "b6e84eea788411f9", "44": "4b603c000aa79db9", "45": "8cda0f1879c40078", "46": "f38c1f1dc0ead50f", "47": "93e8d94e7c998882", "48": "9d46bbe4641a8543", "49": "df2dbdd967da3eaf", "50": "28183579704b35fd", "51": "afc6d855b9fc1b76", "52": "5e7f77f5e6dac074", "53": "e97ba10a1e6a2df3", "54": "34401beb9f60ebfd", "55": "ffa54e3d7035005e", "56": "330644cc56a0e06c", "57": "9e17a2dab584e04c", "58": "90146abe85d9419f", "59": "88f880da5a61f9dc", "60": "356d13eb0dd615ee", "61": "dbfbf8557361f5ea", "62": "6e90b41fbd82c265", "63": "6af26e59b0e8c354", "64": "8ae1cb991df55ba5", "65": "7b932cfef527d130", "66": "3de85332d944dae4", "67": "0f632fc57e9feeb0", "68": "92d7e4efe25770db", "69": "2937001282a8a8b6", "70": "df7ea8ce3fa52be3", "71": "fd47ef3914adf11e", "72": "9b3249f4a4e6fbfe", "73": "39cd7adefdc0d3ce", "74": "a534e43d03671a7c", "75": "5290f7a7558a9c3e", "76": "a19c211702401a5b", "77": "35aa0fc2a0626f70", "78": "996c5e480613f739", "79": "da683f642f8c61ac", "80": "40313433c93b7054", "81": "81af0a28b68936d9", "82": "25287261c3d1c44b", "83": "bda9be183e0f933e", "84": "63526d01888c4f22", "85": "ad4f4a79134b4346", "86": "694a38d63caf7217", "87": "f220eaa4fac26e3e", "88": "d78c8e21a45613e9", "89": "e5ca736ea1d10113", "90": "03f8896e9b60ca96", "91": "5b7e9b89423f4164", "92": "4bfa64a1cd8c6a47", "93": "057eae5a523d2004", "94": "5de74655cbc56e06", "95": "7767ac1c95366fb0", "96": "f36a8effec98632a", "97": "de6816239e70e6e8", "98": "7508d3e84c588617", "99": "15f6a40009e539bb", "100": "f4ecac15615b820e", "101": "4b7f81f0e7e4c044", "102": "7c85c7a24cf3cbed", "103": "3ec6277e63307488", "104": "756a405f723988ce", "105": "d55977ec0e3ae213", "106": "64fb409201f034b8", "107": "19d9362d6aa40786", "108": "b738c5aefe939cff", "109": "d2001d7d283134ac", "110": "6b8e6100893a6b6a", "111": "66ed76491a412a72", "112": "e67668b62ee6c62c", "113": "6bb5ee4a70642036", "114": "68aee39cd24db354", "115": "054401d27ffaa4b2", "116": "dace81c1995fb321", "117": "19e301aeace95bb2", "118": "d6fd0cec4634a0b0", "119": "0230d355a6dabb54", "120": "5cebbb5528321795", "121": "536fe28d24c361f5", "122": "b08bdc2712cb0f12", "123": "6ce8444f504022cc", "124": "8021f52559633383", "125": "d8e34a3b3eb43ee0", "126": "171c34c74a95481b", "127": "73e9eca6f2ca1c9b", "128": "dcc2230ae751c29a", "129": "2a617b932f5d92b2", "130": "d55cb638532fdfeb", "131": "06cae4389208269f", "132": "e9c0a7be255e9b26", "133": "455b4ca39e55ecb0", "134": "8105c55538c34081", "135": "74304670b54c018f", "136": "a27f876eabb044b2", "137": "73f74dd7a40776d6", "138": "29d433d164b99f89", "139": "3f44aa48aa05d597", "140": "ca8a7efba0a3e579", "141": "5cc770af9d184d77", "142": "2d31fa723128b164", "143": "be720fc0071f0c3d", "144": "9d814c2520329846", "145": "f97d1a5c3a066598", "146": "14f84a6e4cd311d6", "147": "3a592d27b1f4ccc9", "148": "0bf6f9d0aeab4fd8", "149": "8b491bb3ff0649dd", "150": "638812cee15d1b9b", "151": "3dd16d68928a479c", "152": "03a0ee43e18f7934", "153": "b672f7977ddfa52e", "154": "435331945d6aad17", "155": "968e1a135dc062b7", "156": "d1a987a548e55a76", "157": "a9bf69b3d550abef", "158": "b039e1c69c8fb7b9", "159": "60029bf0948fc02a", "160": "72dad91d66d471ce", "161": "353a9d09eaae47ea", "162": "43bbf35b96423d46", "163": "4faac8c35fa8b703", "164": "76d6488c0976adbc", "165": "652eabd3db52041c", "166": "bca6f6760a942d10", "167": "ce80aac5f4c439da", "168": "1abe2e7b0bcbeca9", "169": "cfbcf8cef78c5757", "170": "c9f25e20d3a7d5d8", "171": "7e818e8715c414bd", "172": "8d68a258e8ec738b", "173": "827420b645a55301", "174": "19e1beef5d40e7ac", "175": "d6451fb8a1e2d3ca", "176": "adac855b05ea55ee", "177": "95a53382a8e217be", "178": "c67bd11d1973d6ac", "179": "63c999649e523e73", "180": "56f7f3573c34f651", "181": "f8f5346df7d0a239", "182": "9c5614d4fee3efc0", "183": "6886ad7a5751faca", "184": "a1a1f3c1cdff72cf", "185": "32f7ccd6bd4068c7", "186": "1d89c741fb1f9b27", "187": "a800ecffc01fc9d5", "188": "f97753a176d20dae", "189"Line truncated
{"0": "0b739897e89c7b41", "1": "c845994d0f12d034", "2": "8c7679f720857bf1", "3": "9653c364b823290d", "4": "ea39680823c06133", "5": "1c7a6b6536eca5f4", "6": "6b788d0721a913f7", "7": "97d81e46229f8a49", "8": "313943d53fc2bd9f", "9": "da38f92f51e93c79", "10": "0153b3a4e04dd75a", "11": "41d17fe9560c47f6", "12": "d6d2ce2d7d595ba2", "13": "e799ebae9c509cb3", "14": "48d0d033c0d2964f", "15": "58e9c0768a2c889d", "16": "e4b58f9f9e36d64a", "17": "9eec9953ee3ca67f", "18": "1b6aefd19ce25cbe", "19": "a7ba24b2eb2edbbe", "20": "a8997e767700eb26", "21": "0020ce7d1908ad7a", "22": "9701f1591d8fda6e", "23": "7cd46ce4c8fb3ca4", "24": "2ffddab549a70d4b", "25": "90da597ad2a7919f", "26": "c2574e654e1d0322", "27": "994c5e2378052e94", "28": "9a4550f6e7e78e08", "29": "ff4922906a507758", "30": "e4f88099ea3ccfa4", "31": "4d4f780fb4e7f8ea", "32": "293ee6609d9f939d", "33": "0564a8d4ebd069cc", "34": "d92b5a81599860af", "35": "0928f3aa4e597117", "36": "b584145a0a6af86a", "37": "d36df3c85181f806", "38": "bc695fcee2050806", "39": "b5fb0ddc2a912cb0", "40": "ef568e093f9cb1b3", "41": "18750a5fb3366fa5", "42": "bbd8a9066e48dada", "43": "0822c3c7bdd37717", "44": "159d7e82897e35fc", "45": "a7e9b25451da94c4", "46": "f38c1f1dc0ead50f", "47": "9004ecf9c7067aa0", "48": "67d2b4075e544a13", "49": "df2dbdd967da3eaf", "50": "8a2fd7414c9ee9e6", "51": "afc6d855b9fc1b76", "52": "5b43fbf8fcf8716d", "53": "e97ba10a1e6a2df3", "54": "34401beb9f60ebfd", "55": "ffa54e3d7035005e", "56": "330644cc56a0e06c", "57": "e2cb064067d950bd", "58": "90146abe85d9419f", "59": "88f880da5a61f9dc", "60": "306fc8a49c104841", "61": "dbfbf8557361f5ea", "62": "6e90b41fbd82c265", "63": "6af26e59b0e8c354", "64": "8ae1cb991df55ba5", "65": "870e90323abfd449", "66": "8f5a6aa62c618b5f", "67": "0f632fc57e9feeb0", "68": "92d7e4efe25770db", "69": "edda1f9fc3ddc6f9", "70": "df7ea8ce3fa52be3", "71": "fd47ef3914adf11e", "72": "9b3249f4a4e6fbfe", "73": "12a25396ede4f1e9", "74": "a534e43d03671a7c", "75": "0d14f943dc48c339", "76": "a19c211702401a5b", "77": "35aa0fc2a0626f70", "78": "fed2ae936b50d86d", "79": "da683f642f8c61ac", "80": "40313433c93b7054", "81": "81af0a28b68936d9", "82": "25287261c3d1c44b", "83": "bda9be183e0f933e", "84": "63526d01888c4f22", "85": "0bf99e0a63147db6", "86": "694a38d63caf7217", "87": "f220eaa4fac26e3e", "88": "d78c8e21a45613e9", "89": "e5ca736ea1d10113", "90": "03f8896e9b60ca96", "91": "4d4f5e897228ae79", "92": "4bfa64a1cd8c6a47", "93": "057eae5a523d2004", "94": "5de74655cbc56e06", "95": "b4f836f96422aa82", "96": "8d8fa0ac595a0ab7", "97": "19b821f40f591523", "98": "7508d3e84c588617", "99": "15f6a40009e539bb", "100": "634340879ffb956e", "101": "e752861d6bd6141e", "102": "9bb0051cf12f369d", "103": "85ecb40ed558ed43", "104": "756a405f723988ce", "105": "d55977ec0e3ae213", "106": "5970abbcf384c31d", "107": "06b5a3c2baa12fdb", "108": "b738c5aefe939cff", "109": "d2001d7d283134ac", "110": "6b8e6100893a6b6a", "111": "66ed76491a412a72", "112": "e67668b62ee6c62c", "113": "38b87928585aa997", "114": "68aee39cd24db354", "115": "054401d27ffaa4b2", "116": "dace81c1995fb321", "117": "19e301aeace95bb2", "118": "d6fd0cec4634a0b0", "119": "0230d355a6dabb54", "120": "5cebbb5528321795", "121": "536fe28d24c361f5", "122": "b08bdc2712cb0f12", "123": "6ce8444f504022cc", "124": "8021f52559633383", "125": "d8e34a3b3eb43ee0", "126": "171c34c74a95481b", "127": "73e9eca6f2ca1c9b", "128": "dcc2230ae751c29a", "129": "2a617b932f5d92b2", "130": "d55cb638532fdfeb", "131": "06cae4389208269f", "132": "e9c0a7be255e9b26", "133": "455b4ca39e55ecb0", "134": "8105c55538c34081", "135": "74304670b54c018f", "136": "a27f876eabb044b2", "137": "73f74dd7a40776d6", "138": "29d433d164b99f89", "139": "3f44aa48aa05d597", "140": "ca8a7efba0a3e579", "141": "5cc770af9d184d77", "142": "2d31fa723128b164", "143": "be720fc0071f0c3d", "144": "9d814c2520329846", "145": "f97d1a5c3a066598", "146": "14f84a6e4cd311d6", "147": "3a592d27b1f4ccc9", "148": "0bf6f9d0aeab4fd8", "149": "8b491bb3ff0649dd", "150": "638812cee15d1b9b", "151": "3dd16d68928a479c", "152": "03a0ee43e18f7934", "153": "1b4ee66550701d94", "154": "435331945d6aad17", "155": "968e1a135dc062b7", "156": "d1a987a548e55a76", "157": "a9bf69b3d550abef", "158": "b039e1c69c8fb7b9", "159": "60029bf0948fc02a", "160": "72dad91d66d471ce", "161": "353a9d09eaae47ea", "162": "43bbf35b96423d46", "163": "4faac8c35fa8b703", "164": "4af634c3c14ff03b", "165": "652eabd3db52041c", "166": "bca6f6760a942d10", "167": "29cb511ef6daa0c1", "168": "1abe2e7b0bcbeca9", "169": "cfbcf8cef78c5757", "170": "c9f25e20d3a7d5d8", "171": "7e818e8715c414bd", "172": "8d68a258e8ec738b", "173": "827420b645a55301", "174": "19e1beef5d40e7ac", "175": "43e1b3409f1becc6", "176": "adac855b05ea55ee", "177": "237c0746be1bce16", "178": "c67bd11d1973d6ac", "179": "63c999649e523e73", "180": "56f7f3573c34f651", "181": "f8f5346df7d0a239", "182": "43b977f2b2e6524e", "183": "6886ad7a5751faca", "184": "bfad11052c54a1cc", "185": "32f7ccd6bd4068c7", "186": "3f88bb4be9a0294a", "187": "f3756e86a6b2bee6", "188": "5bb9e4f75573b97c", "189"Line truncated
+261 -173
View File
@@ -1,23 +1,23 @@
# Graph Report - khatibi (2026-10-20)
# Graph Report - khatibi (2026-10-22)
## Corpus Check
- 197 files · ~287,853 words
- 204 files · ~314,556 words
- Verdict: corpus is large enough that graph structure adds value.
- Unclassified: 8 file(s) not represented in the graph (top: (none) 5, .env 2, .csv 1)
## Summary
- 1991 nodes · 3632 edges · 207 communities (87 shown, 120 thin omitted)
- Extraction: 95% EXTRACTED · 5% INFERRED · 0% AMBIGUOUS · INFERRED: 188 edges (avg confidence: 0.89)
- 2509 nodes · 4876 edges · 223 communities (104 shown, 119 thin omitted)
- Extraction: 95% EXTRACTED · 5% INFERRED · 0% AMBIGUOUS · INFERRED: 234 edges (avg confidence: 0.91)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `1fd27d04`
- Built from commit: `99c25a82`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
## Community Hubs (Navigation)
- views/sms.py
- run_all
- AutoSendTests
- django_db
- views/store.py
- ModelsPackageContractTests
@@ -27,13 +27,14 @@
- views/customers.py
- django_db_models_deletion
- PanelTestCase
- make_customer
- mark_primary
- EventTriggerTests
- views/finance.py
- django_conf
- sms0098ws.py
- Admin Panel Customization
- User
- apply_timing
- views/actions.py
- timing_for
- InactiveCustomerHiddenTests
- normalize_mobile
- Login Template Forms
@@ -41,12 +42,12 @@
- factories.py
- Rip'n Fast. Fewer Tokens. Better Code.
- views/factors.py
- sms0098.py
- SMSResult
- make_user
- QueueReviewTests
- NoticeMechanismTests
- name_fix.py
- enqueue_sms
- scanner.py
- ZeroZeroNinetyEightWebServiceTests
- ScannerTests
- admin_panel/templates/admin_panel/baseHesabdari.html
@@ -59,47 +60,47 @@
- SMS Integration Docs
- enqueue.py
- django_shortcuts
- test_inventory.py
- stock_in
- admin_panel/templates/admin_panel/customerAction.html
- SmsReportTests
- write_tree
- generate_snapshot
- CustomerInfoCardUITests
- TestCase
- enqueue_sms
- Phone Cleanup Guide
- Customer
- .run_command
- project_tree.py
- Agent Operating Guidelines
- Command
- BehinSMSProvider
- activate_sms
- run_all
- Financial Data Objects
- Hybrid Migration Command
- .run_cmd
- PhoneNumber
- Resource Cleanup Handler
- SMS Timing Migration
- admin_panel/templates/admin_panel/bedehi.html
- customerIDFactorBackup.html
- bale/providers.py
- os
- VisitHoldTests
- reconciliation_tool.py
- Graphify Tooling Docs
- Command
- ZeroZeroNinetyEightLinkTests
- SMS Template Seeding
- admin_panel/entezar.html
- customerNewBackup.html
- django_conf
- SMSProvider
- 0070_sms_types_seven.py
- Endpoint Smoke Tests
- ViewSmokeTests
- Timezone Migration History
- Command
- UrlContractTests
- TodayFollowupTests
- Brand Scrubbing Migration
- SMS Dedup Migration
- Installation Guide
- Project Honesty Contract
- Flow Control Migration
- Permission Label Migration
- PhoneNumber
- visit_policy.py
- Output Format Specs
- Performance Benchmarks
- SMS Integration Overview
@@ -109,23 +110,23 @@
- Infrastructure Services
- Agent Setup Guide
- First Use Guide
- ConsoleEncodingTests
- test_factors.py
- console.py
- test_sms.py
- تجمیع شماره‌های پیامک مشتریان
- Command
- Base Template
- test_sms.py
- Command
- test_customer_name_fix.py
- Customer
- WorkerTests
- manage.py
- افزودن پیشوند «آقای/خانم» به نام مشتریان
- Command
- QueueStatsTests
- signals.py
- make_customer
- admin_panel/apps.py
- Customer Communication Partials
- SMS Template Management
- Documentation and Graphing
- recipients.py
- resolve_recipients
- Customer Action Templates
- Customer Feedback Components
- Customer Invoice Details
@@ -155,14 +156,14 @@
- Store Transfer Interface
- Store Directory Template
- Currency Formatting Utility
- HttpProviderRobustnessTests
- HTTPSMSProvider
- Data Cleanup Documentation
- Project Dependencies
- Customer
- factory.py
- EntezarPageControlsTests
- 0075_cheque_funding_before_due.py
- توقف و ادامه (Resume / Checkpoint)
- Command
- RecalcCommandTests
- 0071_sms_settings_permission.py
- 0056_auto_20260731_1824.py
- الگوریتم تصمیم (به همین ترتیب)
@@ -170,59 +171,78 @@
- 0073_alter_action_actiontype_choices.py
- stop.sh
- 0074_alter_action_status_choices.py
- remove_query_param
- at
- اجرای محلی (توسعه)
- .test_deleted_visit_reminder_rearms_next_period_only
- apply_timing
- اجرا روی سرور Production
- اجرای محلی (توسعه)
- ادامهٔ job پس از crash / قطع برق
- اجرا روی سرور Production
- os
- 0005_auto_20220121_2054.py
- 0009_remove_storeroom_user.py
- test_visit_policy.py
- 0014_auto_20220123_1348.py
- 0015_auto_20220124_0858.py
- 0017_factoriteam_garantyesh.py
- 0022_article_typepy.py
- 0026_auto_20220209_1924.py
- ActionSoftDeleteTests
- last_visit_map
- CoordinationDoneConfirmTests
- BaseTemplateStructureTests
- 0028_action_grade.py
- 0032_article_paystatus.py
- 0033_article_nahvepardakht.py
- validate_action_payload
- NextAppointmentTests
- 0034_alter_article_sarresid.py
- 0035_alter_article_sarresid.py
- 0040_customer_laghv_bazdid.py
- django_contrib_auth_models
- entezar
- 0041_auto_20240216_1607.py
- 0048_alter_productinstore_options.py
- 0053_storeroom_owners.py
- 0055_action_nextnazardt.py
- Action
- بخش دوم — امکانات جدید رابط کاربری صفحهٔ انتظار
- بازمحاسبهٔ «آخرین بازدید» مشتریان
- 0062_sms_link_provider.py
- 0063_alter_smsqueue_status.py
- 0068_auto_20261010_1305.py
- TestCase
- newFactor
- 0072_customernamefixjob.py
- 0077_alter_smstemplate_text_balance_help.py
- «قرار بعدی» بازدید و صفحهٔ انتظار — توضیح کامل عملکرد
- SMSDeliveryCommandTests
- ActionModalTemplateTests
- customer_balance
- VisitDecisionTests
- EntezarScannerConsistencyTests
- EntezarLayoutTests
- signals.py
- ۴. فرم ثبت اقدام (UI)
- ۱.۱ نمای «نیازمند پیگیری امروز» (`?view=today`) — **پیش‌فرض صفحه**
- cleanup_visit_dedup.py
- ۶.۱ حذف یک اقدام (soft-delete)
- active_deferral
- index
- 0025_action_actiontype.py
- 0027_alter_action_nazar.py
- 0029_alter_article_options.py
- 0030_auto_20220414_1047.py
## God Nodes (most connected - your core abstractions)
1. `make_customer()` - 89 edges
2. `run_all()` - 28 edges
3. `make_user()` - 28 edges
4. `normalize_mobile()` - 27 edges
5. `Customer` - 24 edges
6. `PhoneNumber` - 24 edges
7. `Command` - 24 edges
8. `enqueue_sms()` - 24 edges
9. `SMSResult` - 23 edges
10. `Rip'n Fast. Fewer Tokens. Better Code.` - 23 edges
1. `make_customer()` - 111 edges
2. `as_aware()` - 50 edges
3. `make_user()` - 42 edges
4. `at()` - 42 edges
5. `VisitHoldTests` - 41 edges
6. `TodayFollowupTests` - 38 edges
7. `Customer` - 37 edges
8. `PhoneNumber` - 30 edges
9. `make_visit()` - 29 edges
10. `EntezarPageControlsTests` - 28 edges
## Surprising Connections (you probably didn't know these)
- `نکته‌های مهم` --references--> `Action` [INFERRED]
recalc_last_bazdid.md → admin_panel/models/actions.py
- `مدل وضعیت job` --references--> `CustomerNameFixJob` [INFERRED]
fix_customer_names.md → admin_panel/models/system.py
- `چرا؟` --references--> `CustomerNameFixJob` [INFERRED]
fix_customer_names.md → admin_panel/models/system.py
- `سریع‌ترین مسیر روی Production (اگر همین یک دستور را می‌خواهی)` --references--> `CustomerNameFixJob` [INFERRED]
normalize_customer_sms_phones.md → admin_panel/models/system.py
- `تست‌ها` --references--> `resolve_recipients()` [INFERRED]
normalize_customer_sms_phones.md → admin_panel/sms/recipients.py
- `۷. سناریوهای تست‌شده` --references--> `next_appointment_map()` [INFERRED]
docs/visit_hold_entezar.md → admin_panel/visit_policy.py
- `ایمنی ویرایش رکوردهای قدیمی` --references--> `manual_holds_map()` [INFERRED]
docs/visit_hold_entezar.md → admin_panel/visit_policy.py
- `هشدار «پیگیری‌های عقب‌افتاده»` --references--> `overdue_followups()` [INFERRED]
docs/visit_hold_entezar.md → admin_panel/visit_policy.py
## Import Cycles
- None detected.
@@ -255,15 +275,15 @@
- **New Factor Creation Flow** — admin_panel_templates_admin_panel_customeridbackup_html, django_url_admin_panel_productsjson, api_endpoint_panel_productmojodi, admin_panel_templates_admin_panel_customeridbackup_html_orderlist, admin_panel_templates_admin_panel_customeridbackup_html_buildorderlist, django_url_admin_panel_newfactor [INFERRED]
- **Store and Inventory Management Flow** — admin_panel_templates_admin_panel_basestore, concept_url_admin_panel_stores, concept_url_admin_panel_productadd, concept_url_admin_panel_operations, concept_url_admin_panel_store_transfers [INFERRED]
## Communities (207 total, 120 thin omitted)
## Communities (223 total, 119 thin omitted)
### Community 0 - "views/sms.py"
Cohesion: 0.12
Nodes (18): _approve(), customer_sms_history(), _customer_url(), _notice(), parse_jalali_date(), parse_scan_since(), resolve_recipients_test(), sms_customer_toggle() (+10 more)
Nodes (19): _approve(), customer_sms_history(), _customer_url(), _notice(), parse_jalali_date(), parse_scan_since(), _remember_keys(), resolve_recipients_test() (+11 more)
### Community 2 - "django_db"
Cohesion: 0.05
Nodes (20): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+12 more)
Cohesion: 0.04
Nodes (28): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+20 more)
### Community 3 - "views/store.py"
Cohesion: 0.05
@@ -273,105 +293,101 @@ Nodes (32): Product, Meta, ProductCategory, ProductInStore, Meta, MovementType,
Cohesion: 0.08
Nodes (9): Command, Migration, scrub_legacy_phones(), _append_note(), clean_all(), clean_customer(), clean_legacy_cell(), _numeric_tokens() (+1 more)
### Community 6 - ".load"
Cohesion: 0.19
Nodes (4): 12. Supported languages and formats, Languages, SettingsPanelTests, TestMessageSettingsTests
### Community 7 - "models/sms.py"
Cohesion: 0.06
Nodes (15): CustomerSMSSetting, Meta, SMSDedup, Meta, SMSLog, Meta, SMSProviderConfig, Meta (+7 more)
### Community 9 - "views/customers.py"
Cohesion: 0.10
Nodes (18): normalize_primary_values(), customer_toggle_map(), notify_welcome(), customer(), customerDel(), customerEdit(), customerFactor(), customerFactors() (+10 more)
Nodes (15): customer_toggle_map(), notify_welcome(), customer(), customerDel(), customerEdit(), customerFactor(), customerFactors(), customerNew() (+7 more)
### Community 10 - "django_db_models_deletion"
Cohesion: 0.05
Nodes (19): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+11 more)
Cohesion: 0.07
Nodes (13): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+5 more)
### Community 11 - "PanelTestCase"
Cohesion: 0.15
Nodes (3): EmptyQueueTests, PanelTestCase, SentQueueProtectionTests
Cohesion: 0.18
Nodes (3): DeletedStaysDeletedTests, PanelTestCase, SentQueueProtectionTests
### Community 12 - "make_customer"
Cohesion: 0.10
Nodes (7): resolve_recipients(), تست‌ها, make_customer(), DeletedStaysDeletedTests, mark_primary(), PrimaryNumberPanelTests, RecipientTests
### Community 12 - "mark_primary"
Cohesion: 0.14
Nodes (3): mark_primary(), RecipientTests, WelcomeTriggerTests
### Community 14 - "views/finance.py"
Cohesion: 0.20
Nodes (18): Article, Meta, addSarresid(), bedehi(), bedehiKhadamat(), bedehiNasb(), financial_dashboard(), hesabdari() (+10 more)
Cohesion: 0.17
Nodes (20): Article, Meta, notify_payment_thanks(), customerHesabdariAdd(), addSarresid(), bedehi(), bedehiKhadamat(), bedehiNasb() (+12 more)
### Community 15 - "django_conf"
Cohesion: 0.08
Nodes (11): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+3 more)
### Community 16 - "sms0098ws.py"
Cohesion: 0.11
Nodes (5): to_local(), classify_delivery(), DeliveryCode, describe_send_code(), _as_text()
Cohesion: 0.13
Nodes (5): classify_delivery(), DeliveryCode, describe_send_code(), _as_text(), ZeroZeroNinetyEightWebServiceProvider
### Community 17 - "Admin Panel Customization"
Cohesion: 0.12
Nodes (10): CustomerSMSSettingAdmin, SMSLogAdmin, SMSProviderConfigAdmin, SMSProviderConfigForm, Meta, SMSQueueAdmin, SMSTemplateAdmin, StoreRoomAdmin (+2 more)
### Community 18 - "User"
Cohesion: 0.07
Nodes (34): User, Action, ActionType, Status, VisitQueue, Factor, Meta, StoreRoom (+26 more)
### Community 18 - "views/actions.py"
Cohesion: 0.10
Nodes (25): User, notify_visit_scheduled(), _action_payload_from_request(), ActionList(), ActionListAll(), ActionListToday(), ActionListUser(), ActionListUserToday() (+17 more)
### Community 19 - "apply_timing"
### Community 19 - "timing_for"
Cohesion: 0.12
Nodes (7): apply_timing(), current_timing_value(), timing_for(), sms_template_edit(), sms_templates(), _templates_redirect(), TimingsTests
Nodes (7): current_timing_value(), timing_for(), _sample_context(), sms_template_edit(), sms_templates(), _templates_redirect(), TimingsTests
### Community 20 - "InactiveCustomerHiddenTests"
Cohesion: 0.12
Nodes (3): CustomerCrudTests, CustomerDeleteGuardTests, InactiveCustomerHiddenTests
### Community 21 - "normalize_mobile"
Cohesion: 0.10
Nodes (13): customer_phone_entries(), _iter_sources(), PhoneEntry, resolved_recipients(), save_primary_normalized(), stored_primary_normalized(), to_display(), usable_entries() (+5 more)
Cohesion: 0.09
Nodes (13): customer_phone_entries(), _iter_sources(), normalize_primary_values(), PhoneEntry, primary_phones(), save_primary_normalized(), stored_primary_normalized(), to_display() (+5 more)
### Community 22 - "Login Template Forms"
Cohesion: 0.11
Nodes (17): Django CSRF Token, Django Custom Tags, Django Static Files, Django URL: admin_panel:newStore, Django URL: admin_panel:operations, Django URL: admin_panel:p_login, Django URL: admin_panel:store, Form Field: password (+9 more)
### Community 23 - "cheque_inquiry.py"
Cohesion: 0.17
Cohesion: 0.19
Nodes (6): api_check_sabt_inquiry(), api_check_status_inquiry(), parse_cheque_result_html(), run_bpi_cheque_inquiry_logic(), run_cbi_sayad_inquiry_logic(), solve_base64_captcha()
### Community 24 - "factories.py"
Cohesion: 0.31
Cohesion: 0.20
Nodes (5): ProductCategory, make_category(), make_product(), make_store(), _next()
### Community 25 - "Rip'n Fast. Fewer Tokens. Better Code."
Cohesion: 0.11
Nodes (18): Against the leading graph-database code-context MCP server, Better Code: It automates the review judgments nobody has time to make — every lens from published research, Documentation, Graph-Ranked Retrieval: It finds the right files more often than the alternatives, If it works on your codebase, tell us what it got wrong, Improve it with your agent, Licence, Quickstart (+10 more)
Nodes (19): Against the leading graph-database code-context MCP server, Better Code: It automates the review judgments nobody has time to make — every lens from published research, Documentation, Graph-Ranked Retrieval: It finds the right files more often than the alternatives, If it works on your codebase, tell us what it got wrong, Improve it with your agent, Languages, Licence (+11 more)
### Community 26 - "views/factors.py"
Cohesion: 0.12
Nodes (10): FactorIteam, notify_factor_survey(), notify_payment_thanks(), FactorDel(), newFactor(), newFactor2(), InventoryException, _parse_jalali_date() (+2 more)
Cohesion: 0.18
Nodes (6): notify_factor_survey(), newFactor2(), InventoryException, _parse_jalali_date(), _parse_timestamp(), _safe_int()
### Community 27 - "sms0098.py"
Cohesion: 0.20
Nodes (3): _configured_codes(), _response_code(), ZeroZeroNinetyEightSMSProvider
### Community 27 - "SMSResult"
Cohesion: 0.18
Nodes (5): SMSResult, _configured_codes(), _response_code(), ZeroZeroNinetyEightSMSProvider, _FailingProvider
### Community 30 - "NoticeMechanismTests"
Cohesion: 0.17
Nodes (3): dashBoxData(), resolve(), NoticeMechanismTests
### Community 31 - "name_fix.py"
Cohesion: 0.13
Nodes (6): build_name_index(), DatasetError, load_name_index(), normalize_name_key(), validate_dataset(), NameIndexTests
### Community 32 - "enqueue_sms"
Cohesion: 0.21
Nodes (7): enqueue_sms(), _aware(), _Budget, scan_cheque_funding(), scan_nesiye_due(), scan_visit(), _template()
### Community 33 - "ZeroZeroNinetyEightWebServiceTests"
Cohesion: 0.12
Nodes (4): SMSDeliveryResult, ZeroZeroNinetyEightWebServiceProvider, SMSDeliveryCommandTests, ZeroZeroNinetyEightWebServiceTests
Nodes (8): build_name_index(), DatasetError, load_name_index(), NameIndex, normalize_name_key(), validate_dataset(), NameIndexTests, sha256_of()
### Community 32 - "scanner.py"
Cohesion: 0.12
Nodes (12): _aware(), _Budget, scan_cheque_funding(), scan_nesiye_due(), scan_visit(), _template(), cycle_anchor_dt(), cycle_anchor_map() (+4 more)
### Community 35 - "admin_panel/templates/admin_panel/baseHesabdari.html"
Cohesion: 0.13
Nodes (14): URL: admin_panel:ActionListUserToday, URL: admin_panel:bedehi, URL: admin_panel:entezar, URL: admin_panel:hesabdari, URL: admin_panel:hesabdari_residnashone, URL: admin_panel:index, URL: admin_panel:p_logout, URL: admin_panel:rezayat (+6 more)
### Community 36 - "Reference guide"
Cohesion: 0.14
Nodes (14): 10. Quality evaluation, 11. Agent integration, 13. Performance, 14. Known limits, 15. Documentation, 16. Improvement, 17. License, 1. Purpose and function (+6 more)
Cohesion: 0.13
Nodes (15): 10. Quality evaluation, 11. Agent integration, 12. Supported languages and formats, 13. Performance, 14. Known limits, 15. Documentation, 16. Improvement, 17. License (+7 more)
### Community 37 - "Factor Creation UI"
Cohesion: 0.15
@@ -382,27 +398,23 @@ Cohesion: 0.19
Nodes (11): admin_panel:customerNew, admin_panel:pardakhtNesiye, /panel/passCheck/, all_sarresid, mandehesabKP, admin_panel.ViewReportMali, jQuery, pDatepicker JS Library (+3 more)
### Community 39 - "requests"
Cohesion: 0.25
Nodes (4): AnnouncementRejected, place_announcement_call(), place_voip_call(), VoipCallResult
Cohesion: 0.13
Nodes (8): AnnouncementRejected, place_announcement_call(), BaleBotProvider, ConsoleBaleProvider, get_bale_provider(), NotificationResult, place_voip_call(), VoipCallResult
### Community 40 - "CustomerSmsHistoryTests"
Cohesion: 0.15
Nodes (3): customer_phones(), toJalaliDT(), CustomerSmsHistoryTests
Cohesion: 0.09
Nodes (5): customer_phones(), row_for(), toJalaliDT(), CustomerSmsHistoryTests, QueueStatsTests
### Community 42 - "SMS Integration Docs"
Cohesion: 0.15
Nodes (12): **جدول کدهای خطای لینک ارتباطی**, **جدول کدهای خطای وب‌سرویس و وضعیت‌های دلیوری**, **راهنمای استفاده از Web Service**, **راهنمای استفاده از لینک ارتباطی (URL GET)**, **راهنمای استفاده از لینک ارتباطی و وب‌سرویس سامانه پیامک (0098vip.com و 0098sms.com)**, **فهرست مطالب**, **مقدمات اتصال**, **نمونه کدهای پیاده‌سازی** (+4 more)
### Community 43 - "enqueue.py"
Cohesion: 0.13
Nodes (6): customer_balance(), format_amount(), format_jalali(), render_template(), _SafeDict, _sample_context()
Cohesion: 0.21
Nodes (4): format_amount(), format_jalali(), render_template(), _SafeDict
### Community 44 - "django_shortcuts"
Cohesion: 0.13
Nodes (5): ArticleType, Sarresid, index(), `nest=` is a max, so it cannot tell a long function from a tangled one, The quality panel
### Community 45 - "test_inventory.py"
Cohesion: 0.11
### Community 45 - "stock_in"
Cohesion: 0.12
Nodes (11): Factor, FactorIteam, Product, ProductInStore, StoreRoom, VisitQueue, current_stock(), stock_in() (+3 more)
### Community 46 - "admin_panel/templates/admin_panel/customerAction.html"
@@ -413,21 +425,21 @@ Nodes (9): admin_panel/templates/admin_panel/customerActionInc.html, admin_panel
Cohesion: 0.10
Nodes (7): describe(), detect_encoding(), encoded_length(), is_gsm7(), parts_count(), SmsPartsTests, SmsReportTests
### Community 48 - "write_tree"
Cohesion: 0.21
Nodes (6): Counts, format_header(), generate_snapshot(), normalized_path(), write_tree(), visit()
### Community 48 - "generate_snapshot"
Cohesion: 0.32
Nodes (3): Counts, format_header(), generate_snapshot()
### Community 50 - "TestCase"
Cohesion: 0.20
Nodes (3): is_sms_enabled(), EnqueueIdempotencyTests, RuleTests
### Community 50 - "enqueue_sms"
Cohesion: 0.21
Nodes (4): enqueue_sms(), is_sms_enabled(), EnqueueIdempotencyTests, RuleTests
### Community 51 - "Phone Cleanup Guide"
Cohesion: 0.17
Nodes (11): اجرا روی سرور اصلی (production), اجرای دستور, اصلاح دستی شماره‌های قدیمی مشتریان, راه جایگزین: مهاجرت (migration), قاعدهٔ پاک‌سازی, مشاهدهٔ نتیجه, نکته‌ها, چرا؟ (+3 more)
### Community 52 - "Customer"
Cohesion: 0.10
Nodes (8): Customer, ApplyTests, CommandTestMixin, DatasetFixtureMixin, DryRunTests, ResilienceTests, flaky(), sha256_of()
### Community 52 - ".run_command"
Cohesion: 0.13
Nodes (5): ApplyTests, CommandTestMixin, DryRunTests, ResilienceTests, flaky()
### Community 53 - "project_tree.py"
Cohesion: 0.23
@@ -437,13 +449,17 @@ Nodes (6): is_excluded_file(), main(), non_negative_integer(), parse_args(), sor
Cohesion: 0.18
Nodes (8): 1. Codebase Navigation & Analysis (Ripwire vs. Graphify), 2. Dynamic Help, Self-Correction & Staleness, 3. Git Operations & Safety, 4. Development Server Lifecycle, 5. Environment, Frontend & PWA Safety, 6. End-to-End Browser Tests (Playwright), Project Operating & Agent Guidelines, Routing Gate & Pipeline
### Community 57 - "run_all"
Cohesion: 0.25
Nodes (3): run_all(), activate_sms(), ColdStartTests
### Community 58 - "Financial Data Objects"
Cohesion: 0.20
Nodes (9): Customer Data Object, Factor Data Object, Sarresid Data Object, admin_panel:customerNew URL, admin_panel:pardakhtNesiye URL, /panel/passCheck/ URL, admin_panel.ViewReportMali Permission, jQuery JS Library (+1 more)
### Community 60 - ".run_cmd"
Cohesion: 0.10
Nodes (6): ApplyTests, BatchAndCheckpointTests, flaky(), CommandTestCase, DryRunTests, make_phone()
### Community 60 - "PhoneNumber"
Cohesion: 0.11
Nodes (8): PhoneNumber, BatchAndCheckpointTests, flaky(), CommandTestCase, DryRunTests, IntegrationAndEfficiencyTests, make_phone(), OverflowTests
### Community 62 - "SMS Timing Migration"
Cohesion: 0.22
@@ -457,13 +473,9 @@ Nodes (8): Articl Model, Customer Model, articls, toJalali, admin_panel:bedehi,
Cohesion: 0.20
Nodes (7): Client-side script in customerIDFactorBackup.html, URL: admin_panel:customerEdit, Factor Data Model, Product Data Model, Product Category Data Model, User Data Model, URL: /panel/productMojodi/
### Community 65 - "bale/providers.py"
Cohesion: 0.36
Nodes (4): BaleBotProvider, ConsoleBaleProvider, get_bale_provider(), NotificationResult
### Community 66 - "os"
Cohesion: 0.20
Nodes (3): check_financial_discrepancy(), run_360_audit(), run_audit()
### Community 66 - "reconciliation_tool.py"
Cohesion: 0.19
Nodes (4): remove_query_param(), check_financial_discrepancy(), run_360_audit(), run_audit()
### Community 67 - "Graphify Tooling Docs"
Cohesion: 0.22
@@ -477,9 +489,9 @@ Nodes (6): Customer Data Structure, kamaDatepicker Library, URL: admin_panel:cus
Cohesion: 0.25
Nodes (7): admin_panel:newFactor URL, Category Data, Client-side Form Logic, Customer Data, CSRF Token, custom_tags, static
### Community 73 - "django_conf"
### Community 73 - "SMSProvider"
Cohesion: 0.13
Nodes (7): SMSProvider, SMSResult, webotp_message(), console_write(), ConsoleSMSProvider, HTTPSMSProvider, _FailingProvider
Nodes (4): SMSProvider, get_provider_class(), get_sms_provider(), reset_sms_provider()
### Community 76 - "Timezone Migration History"
Cohesion: 0.29
@@ -493,9 +505,9 @@ Nodes (5): 3.1 Install a prebuilt binary, 3.2 Build from source, 3.3 Install the
Cohesion: 0.40
Nodes (5): In the numbers, In the output, In the tests, The honesty contract, What it misses, and what to run next
### Community 85 - "PhoneNumber"
Cohesion: 0.20
Nodes (4): PhoneNumber, IntegrationAndEfficiencyTests, OverflowTests, WelcomeTriggerTests
### Community 85 - "visit_policy.py"
Cohesion: 0.08
Nodes (20): _action_appointments(), ActionDeleteError, appointment_sources_map(), cancel_pending_sms_for_action(), coordination_holds_map(), effective_visit_dt(), followups_on_date(), _future_actions() (+12 more)
### Community 86 - "Output Format Specs"
Cohesion: 0.50
@@ -518,8 +530,8 @@ Cohesion: 0.67
Nodes (3): createCS click handler, openRow click handler, Customer Modals UI
### Community 91 - "decide_prefix"
Cohesion: 0.18
Nodes (4): decide_prefix(), Decision, NameIndex, DecidePrefixTests
Cohesion: 0.14
Nodes (4): decide_prefix(), Decision, DatasetFixtureMixin, DecidePrefixTests
### Community 92 - "Infrastructure Services"
Cohesion: 0.67
@@ -533,46 +545,58 @@ Nodes (3): 1. (Optional) Register the MCP server, 2. Install the skills, Set it
Cohesion: 0.67
Nodes (3): 4.1 Make the first map, 4.2 Use the common commands, 4. First use
### Community 95 - "console.py"
Cohesion: 0.15
Nodes (4): webotp_message(), console_write(), ConsoleSMSProvider, ConsoleEncodingTests
### Community 97 - "تجمیع شماره‌های پیامک مشتریان"
Cohesion: 0.14
Nodes (13): الگوریتم (برای هر مشتری), بازگردانی (اگر نتیجه را نخواستی), بررسی صحت داده (SQL), تجمیع شماره‌های پیامک مشتریان, تعریف کلیدهای گزارش, خطاها و اقدام درست, عملکرد و بار روی دیتابیس, نمونه‌ها (+5 more)
Cohesion: 0.12
Nodes (15): الگوریتم (برای هر مشتری), بازگردانی (اگر نتیجه را نخواستی), بررسی صحت داده (SQL), تجمیع شماره‌های پیامک مشتریان, تست‌ها, تعریف کلیدهای گزارش, خطاها و اقدام درست, عملکرد و بار روی دیتابیس (+7 more)
### Community 99 - "Base Template"
Cohesion: 0.15
Nodes (11): Base Template, Customer Base Template, New Customer Base Template, Create Factor View, Customer List View, Django Custom Tags: custom_tags, URL: admin_panel:newFactor, URL: admin_panel:operations (+3 more)
### Community 100 - "test_sms.py"
Cohesion: 0.10
### Community 100 - "test_customer_name_fix.py"
Cohesion: 0.11
Nodes (3): digit_string(), is_landline(), PureHelperTests
### Community 101 - "Customer"
Cohesion: 0.23
Nodes (3): Customer, Command, ActionDeleteUITests
### Community 103 - "manage.py"
Cohesion: 0.22
Cohesion: 0.24
Nodes (5): entrypoint.sh script, main(), run.sh script, start_service(), usage()
### Community 104 - "افزودن پیشوند «آقای/خانم» به نام مشتریان"
Cohesion: 0.15
Nodes (12): SQLite، قفل دیتابیس و بار روی سایت, افزودن پیشوند «آقای/خانم» به نام مشتریان, بررسی صحت داده (SQL، پیش از/پس از اجرا), بکاپ/بازیابی خودکار نیست, بکاپ پیش از اجرای واقعی, تست‌ها, تعریف کلیدهای آماری, خطاهای رایج و اقدام درست (+4 more)
### Community 107 - "signals.py"
Cohesion: 0.12
Nodes (4): AdminConfig, AdminPanelConfig, make_std_streams_unicode_safe(), configure_sqlite_connection()
### Community 106 - "make_customer"
Cohesion: 0.09
Nodes (4): make_customer(), EmptyQueueTests, ApplyTests, PrimaryNumberPanelTests
### Community 113 - "recipients.py"
Cohesion: 0.33
Nodes (3): primary_phones(), primary_phones(), ⚠️ هشدار معنای داده (مهم‌ترین نکتهٔ این سند)
### Community 107 - "admin_panel/apps.py"
Cohesion: 0.18
Nodes (3): AdminConfig, AdminPanelConfig, make_std_streams_unicode_safe()
### Community 113 - "resolve_recipients"
Cohesion: 0.16
Nodes (5): resolved_recipients(), primary_phones(), resolve_recipients(), قالبِ ذخیره‌شده: `+989xxxxxxxxx`, قواعد اعتبارسنجی و قالب شماره
### Community 163 - "Customer"
Cohesion: 0.29
Nodes (3): Customer, Meta, PhoneNumber
### Community 164 - "factory.py"
Cohesion: 0.29
Nodes (3): get_provider_class(), get_sms_provider(), reset_sms_provider()
### Community 166 - "توقف و ادامه (Resume / Checkpoint)"
Cohesion: 0.40
Nodes (5): اتمیک بودن, توقف و ادامه (Resume / Checkpoint), سلسله‌مراتب تصمیم نقطهٔ شروع, فایل وضعیت, گزینه‌های ادامه
### Community 167 - "RecalcCommandTests"
Cohesion: 0.10
Nodes (5): Command, action_snapshot(), plan_command(), RecalcCommandTests, run_command()
### Community 170 - "الگوریتم تصمیم (به همین ترتیب)"
Cohesion: 0.50
Nodes (4): الگوریتم تصمیم (به همین ترتیب), برخورد با نام‌های تکراری و متناقض, نرمال‌سازی (فقط برای کلید جست‌وجو), نمونه‌های رفتاری
@@ -585,6 +609,10 @@ Nodes (4): اگر وسط کار قطع شد, بررسی نتیجه, سریع‌
Cohesion: 0.73
Nodes (5): is_our_process(), say(), stop.sh script, stop_pid(), windows_cleanup()
### Community 175 - "at"
Cohesion: 0.17
Nodes (6): last_visit_dt(), at(), EffectiveVisitDateTests, HamahangiIsolationTests, make_visit(), RefreshLastBazdidTests
### Community 176 - "اجرای محلی (توسعه)"
Cohesion: 0.50
Nodes (4): اجرای محلی (توسعه), ۱) شبیه‌سازی — همیشه قدم اول, ۲) تنظیم سرعت batch, ۳) اجرای واقعی
@@ -605,30 +633,90 @@ Nodes (3): ادامهٔ job پس از crash / قطع برق, دستورهای د
Cohesion: 0.67
Nodes (3): اجرا روی سرور Production, ترتیب دقیق, نکته‌ها برای Production
### Community 182 - "os"
Cohesion: 0.18
Nodes (3): normalized_path(), write_tree(), visit()
### Community 184 - "test_visit_policy.py"
Cohesion: 0.16
Nodes (6): as_aware(), visit_cycle_index(), due_date(), visit_due_date(), NextAppointmentEquivalenceTests, VisitCadenceTests
### Community 187 - "last_visit_map"
Cohesion: 0.13
Nodes (5): _bucket(), last_visit_map(), _last_visit_map_python(), _visit_rows(), LastVisitMapEquivalenceTests
### Community 194 - "django_contrib_auth_models"
Cohesion: 0.15
Nodes (5): VisitQueue, StoreRoom, Meta, StoreRoomOwner, UserProfile
### Community 195 - "entezar"
Cohesion: 0.19
Nodes (9): entezar(), _apply_search(), _followup_ids(), _followup_link(), _qs(), _sort_value(), _source_date(), visit_period_days() (+1 more)
### Community 197 - "Action"
Cohesion: 0.17
Nodes (9): Action, ActionType, Meta, Status, رفتار دادهٔ موجود, قاعدهٔ طلایی: ستون، «مرجع» نیست, ۲. راه‌حل: ستون «قرار بعدی», ۴. مرتب‌سازی (ستون‌ها و دراپ‌داون) (+1 more)
### Community 198 - "بخش دوم — امکانات جدید رابط کاربری صفحهٔ انتظار"
Cohesion: 0.17
Nodes (11): بخش دوم — امکانات جدید رابط کاربری صفحهٔ انتظار, تفکیک نوع قرار — در دراپ‌داون، نه روی کارت, ۱. نوار آمار (بالای صفحه), ۱۰. پارامترهای URL, ۲. فیلترها (نوار زیر کارت‌ها), ۳. تفکیک مدت تأخیر (کلیک‌پذیر), ۵. جست‌وجو, ۵. چیدمان ستون‌ها (بازطراحی) (+3 more)
### Community 199 - "بازمحاسبهٔ «آخرین بازدید» مشتریان"
Cohesion: 0.18
Nodes (10): اجرا روی production, بازمحاسبهٔ «آخرین بازدید» مشتریان, بررسی صحت داده (SQL، پیش از اجرا), دستور, دقیقاً چه می‌کند؟, نمونهٔ خروجی, نکته‌های مهم, گزینه‌ها (+2 more)
### Community 202 - "newFactor"
Cohesion: 0.22
Nodes (5): Factor, Meta, FactorIteam, FactorDel(), newFactor()
### Community 205 - "«قرار بعدی» بازدید و صفحهٔ انتظار — توضیح کامل عملکرد"
Cohesion: 0.20
Nodes (9): ستون‌های جدول, شمارندهٔ داشبورد, فیلترها (قابل بوکمارک و ترکیب با جست‌وجو), «قرار بعدی» بازدید و صفحهٔ انتظار — توضیح کامل عملکرد, ۱. صورت مسئله, ۳. سه منبع «قرار بعدی», ۵. صفحهٔ انتظار: چهار گروه و فیلترها, ۷. سناریوهای تست‌شده (+1 more)
### Community 210 - "EntezarScannerConsistencyTests"
Cohesion: 0.29
Nodes (3): active_deferrals(), EntezarScannerConsistencyTests, is_stale()
### Community 213 - "۴. فرم ثبت اقدام (UI)"
Cohesion: 0.29
Nodes (7): ایمنی ویرایش رکوردهای قدیمی, بارگذاری مقدار ذخیره‌شده در حالت ویرایش, قاعدهٔ «تمدید مجاز، کوتاه‌کردن ممنوع» (max), قواعد اعتبارسنجی (سرور), ۴. فرم ثبت اقدام (UI), ۴.۲ هشدار زمان‌بندی و پیش‌فرض «انجام شده» (فقط هماهنگی), ۴.۳ راهنمای بازشونده (فقط «هماهنگی»)
### Community 214 - "۱.۱ نمای «نیازمند پیگیری امروز» (`?view=today`) — **پیش‌فرض صفحه**"
Cohesion: 0.29
Nodes (7): این فهرست از کجا می‌آید؟, تفاوت با فهرست «عقب‌افتاده», تقویم شمسی (گذشته و آینده), ستون «تأخیر» در این نما نمایش داده نمی‌شود, قاعدهٔ قطعی: «بازدید» اینجا نمی‌آید, هشدار «پیگیری‌های عقب‌افتاده», ۱.۱ نمای «نیازمند پیگیری امروز» (`?view=today`) — **پیش‌فرض صفحه**
### Community 216 - "۶.۱ حذف یک اقدام (soft-delete)"
Cohesion: 0.40
Nodes (5): اثر روی مصرف‌کننده‌ها, امنیت, قاعدهٔ محصولی: اقدام دارای نظرسنجی حذف نمی‌شود, پیامدهای جانبی (در همان تراکنش), ۶.۱ حذف یک اقدام (soft-delete)
### Community 218 - "index"
Cohesion: 0.67
Nodes (3): index(), `nest=` is a max, so it cannot tell a long function from a tangled one, The quality panel
## Ambiguous Edges - Review These
- `admin_panel/templates/admin_panel/baseHesabdari.html` → `Base Template` [AMBIGUOUS]
admin_panel/templates/admin_panel/baseHesabdari.html · relation: conceptually_related_to
## Knowledge Gaps
- **118 isolated node(s):** `base.html`, `toCurrency`, `DataTables Library`, `Customer Data Object`, `Factor Data Object` (+113 more)
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 858 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
- **120 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 1048 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
- **119 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `make_customer()` connect `make_customer` to `run_all`, `PhoneCleanupTests`, `PanelTestCase`, `EventTriggerTests`, `InactiveCustomerHiddenTests`, `factories.py`, `make_user`, `QueueReviewTests`, `NoticeMechanismTests`, `CustomerSmsHistoryTests`, `test_inventory.py`, `CustomerInfoCardUITests`, `TestCase`, `.test_deleted_visit_reminder_rearms_next_period_only`, `Customer`, `activate_sms`, `.run_cmd`, `.test_visit_period_comes_from_template`, `.test_period_start_filters_out_very_old_customers`, `PhoneNumber`, `ConsoleEncodingTests`, `test_factors.py`, `test_sms.py`, `WorkerTests`, `QueueStatsTests`?**
_High betweenness centrality (0.071) - this node is a cross-community bridge._
- **Why does `make_customer()` connect `make_customer` to `AutoSendTests`, `PhoneCleanupTests`, `PanelTestCase`, `mark_primary`, `EventTriggerTests`, `InactiveCustomerHiddenTests`, `factories.py`, `make_user`, `QueueReviewTests`, `NoticeMechanismTests`, `ScannerTests`, `EntezarPageControlsTests`, `CustomerSmsHistoryTests`, `stock_in`, `at`, `CustomerInfoCardUITests`, `enqueue_sms`, `apply_timing`, `test_visit_policy.py`, `run_all`, `ActionSoftDeleteTests`, `last_visit_map`, `PhoneNumber`, `CoordinationDoneConfirmTests`, `NextAppointmentTests`, `VisitHoldTests`, `TestCase`, `TodayFollowupTests`, `ActionModalTemplateTests`, `VisitDecisionTests`, `EntezarScannerConsistencyTests`, `EntezarLayoutTests`, `decide_prefix`, `console.py`, `test_sms.py`, `test_customer_name_fix.py`, `Customer`, `WorkerTests`, `resolve_recipients`?**
_High betweenness centrality (0.105) - this node is a cross-community bridge._
- **Are the 2 inferred relationships involving `make_customer()` (e.g. with `Customer` and `PhoneNumber`) actually correct?**
_`make_customer()` has 2 INFERRED edges - model-reasoned connections that need verification._
- **What connects `base.html`, `toCurrency`, `DataTables Library` to the rest of the system?**
_118 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `views/sms.py` be split into smaller, more focused modules?**
_Cohesion score 0.11746031746031746 - nodes in this community are weakly interconnected._
- **Why does `Rip'n Fast. Fewer Tokens. Better Code.` connect `Rip'n Fast. Fewer Tokens. Better Code.` to `Reference guide`, `.load`, `django_shortcuts`, `Project Honesty Contract`, `Agent Operating Guidelines`, `Performance Benchmarks`, `Agent Setup Guide`?**
_High betweenness centrality (0.053) - this node is a cross-community bridge._
- **Are the 2 inferred relationships involving `normalize_mobile()` (e.g. with `الگوریتم (برای هر مشتری)` and `قالبِ ذخیره‌شده: `+989xxxxxxxxx``) actually correct?**
_`normalize_mobile()` has 2 INFERRED edges - model-reasoned connections that need verification._
_Cohesion score 0.11711711711711711 - nodes in this community are weakly interconnected._
- **Why does `Customer` connect `Customer` to `AutoSendTests`, `PhoneCleanupTests`, `mark_primary`, `InactiveCustomerHiddenTests`, `ScannerTests`, `EntezarPageControlsTests`, `RecalcCommandTests`, `stock_in`, `at`, `.run_command`, `Command`, `test_visit_policy.py`, `ActionSoftDeleteTests`, `PhoneNumber`, `NextAppointmentTests`, `VisitHoldTests`, `reconciliation_tool.py`, `Command`, `TodayFollowupTests`, `VisitDecisionTests`, `EntezarScannerConsistencyTests`, `EntezarLayoutTests`, `make_customer`?**
_High betweenness centrality (0.040) - this node is a cross-community bridge._
- **Should `django_db` be split into smaller, more focused modules?**
_Cohesion score 0.03508771929824561 - nodes in this community are weakly interconnected._
### Low-confidence Hints
_AMBIGUOUS edges — the extractor was unsure. Verify before acting on these._
+4 -4
View File
@@ -63,12 +63,12 @@
</div>
<div id="legend"></div>
</div>
<div id="stats">1991 nodes &middot; 3632 edges &middot; 207 communities</div>
<div id="stats">2509 nodes &middot; 4876 edges &middot; 223 communities</div>
</div>
<script>
const RAW_NODES = [{"id": "tests_test_sms_autosendtests", "label": "AutoSendTests", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 13.7, "font": {"size": 0, "color": "#ffffff"}, "title": "AutoSendTests", "community": 1, "community_name": "run_all", "source_file": "tests/test_sms.py", "file_type": "code", "degree": 12}, {"id": "admin_panel_migrations_0001_initial_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0001_initial.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0007_productinstore_factor_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0007_productinstore_factor.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0008_storeroom_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0008_storeroom_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0011_storeroom_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0011_storeroom_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0012_visitqueue_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0012_visitqueue_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0016_factor_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0016_factor_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0018_alter_factoriteam_product_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0018_alter_factoriteam_product.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0019_article_articletype_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0019_article_articletype.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0024_action_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0024_action.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0036_sarresid_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0036_sarresid.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0038_auto_20240213_1735_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"backgrounLine truncated
const RAW_EDGES = [{"from": "tests_test_sms", "to": "tests_test_sms_autosendtests", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_paneltestcase", "label": "inherits", "title": "inherits [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_overdue_visit_customer", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_default_is_pending_so_manager_must_approve", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_auto_send_enqueues_approved_without_manager", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_auto_send_does_not_bypass_opt_out_or_disabled", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_welcome_auto_send_skips_approval_on_customer_creation", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_template_edit_persists_auto_send", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_templates_page_shows_auto_send_switch", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_rationale_1183", "to": "tests_test_sms_autosendtests", "label": "rationale_for", "title": "rationale_for [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "admin_models_phonenumber", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "tests_test_sms_autosendtests", "to": "admin_models_customer", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "admin_panel_migrations_0001_initial", "to": "admin_panel_migrations_0001_initial_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0007_productinstore_factor", "to": "admin_panel_migrations_0007_productinstore_factor_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0008_storeroom_user", "to": "admin_panel_migrations_0008_storeroom_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0011_storeroom_user", "to": "admin_panel_migrations_0011_storeroom_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0012_visitqueue_user", "to": "admin_panel_migrations_0012_visitqueue_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0016_factor_user", "to": "admin_panel_migrations_0016_factor_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0018_alter_factoriteam_product", "to": "admin_panel_migrations_0018_alter_factoriteam_product_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0019_article_articletype", "to": "admin_panel_migrations_0019_article_articletype_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0024_action", "to": "adminLine truncated
const LEGEND = [{"cid": 0, "color": "#4E79A7", "label": "views/sms.py", "count": 36}, {"cid": 1, "color": "#F28E2B", "label": "run_all", "count": 15}, {"cid": 2, "color": "#E15759", "label": "django_db", "count": 41}, {"cid": 3, "color": "#76B7B2", "label": "views/store.py", "count": 73}, {"cid": 4, "color": "#59A14F", "label": "ModelsPackageContractTests", "count": 8}, {"cid": 5, "color": "#EDC948", "label": "PhoneCleanupTests", "count": 31}, {"cid": 6, "color": "#B07AA1", "label": ".load", "count": 13}, {"cid": 7, "color": "#FF9DA7", "label": "models/sms.py", "count": 32}, {"cid": 8, "color": "#9C755F", "label": "django_db_models", "count": 27}, {"cid": 9, "color": "#BAB0AC", "label": "views/customers.py", "count": 32}, {"cid": 10, "color": "#4E79A7", "label": "django_db_models_deletion", "count": 39}, {"cid": 11, "color": "#F28E2B", "label": "PanelTestCase", "count": 16}, {"cid": 12, "color": "#E15759", "label": "make_customer", "count": 37}, {"cid": 13, "color": "#76B7B2", "label": "EventTriggerTests", "count": 22}, {"cid": 14, "color": "#59A14F", "label": "views/finance.py", "count": 24}, {"cid": 15, "color": "#EDC948", "label": "django_contrib", "count": 7}, {"cid": 16, "color": "#B07AA1", "label": "sms0098ws.py", "count": 23}, {"cid": 17, "color": "#FF9DA7", "label": "Admin Panel Customization", "count": 22}, {"cid": 18, "color": "#9C755F", "label": "User", "count": 49}, {"cid": 19, "color": "#BAB0AC", "label": "apply_timing", "count": 22}, {"cid": 20, "color": "#4E79A7", "label": "InactiveCustomerHiddenTests", "count": 16}, {"cid": 21, "color": "#F28E2B", "label": "normalize_mobile", "count": 31}, {"cid": 22, "color": "#E15759", "label": "Login Template Forms", "count": 19}, {"cid": 23, "color": "#76B7B2", "label": "cheque_inquiry.py", "count": 20}, {"cid": 24, "color": "#59A14F", "label": "factories.py", "count": 13}, {"cid": 25, "color": "#EDC948", "label": "Rip&#x27;n Fast. Fewer Tokens. Better Code.", "count": 18}, {"cid": 26, "color": "#B07AA1", "label": "views/factors.py", "count": 29}, {"cid": 27, "color": "#FF9DA7", "label": "sms0098.py", "count": 14}, {"cid": 28, "color": "#9C755F", "label": "make_user", "count": 23}, {"cid": 29, "color": "#BAB0AC", "label": "QueueReviewTests", "count": 9}, {"cid": 30, "color": "#4E79A7", "label": "NoticeMechanismTests", "count": 16}, {"cid": 31, "color": "#F28E2B", "label": "name_fix.py", "count": 24}, {"cid": 32, "color": "#E15759", "label": "enqueue_sms", "count": 18}, {"cid": 33, "color": "#76B7B2", "label": "ZeroZeroNinetyEightWebServiceTests", "count": 35}, {"cid": 34, "color": "#59A14F", "label": "ScannerTests", "count": 23}, {"cid": 35, "color": "#EDC948", "label": "admin_panel/templates/admin_panel/baseHesabdari.html", "count": 15}, {"cid": 36, "color": "#B07AA1", "label": "Reference guide", "count": 14}, {"cid": 37, "color": "#FF9DA7", "label": "Factor Creation UI", "count": 14}, {"cid": 38, "color": "#9C755F", "label": "hesabdariResidNashode.html", "count": 14}, {"cid": 39, "color": "#BAB0AC", "label": "requests", "count": 11}, {"cid": 40, "color": "#4E79A7", "label": "CustomerSmsHistoryTests", "count": 24}, {"cid": 41, "color": "#F28E2B", "label": "TemplatesPageUITests", "count": 14}, {"cid": 42, "color": "#E15759", "label": "SMS Integration Docs", "count": 13}, {"cid": 43, "color": "#76B7B2", "label": "enqueue.py", "count": 20}, {"cid": 44, "color": "#59A14F", "label": "django_shortcuts", "count": 15}, {"cid": 45, "color": "#EDC948", "label": "test_inventory.py", "count": 30}, {"cid": 46, "color": "#B07AA1", "label": "admin_panel/templates/admin_panel/customerAction.html", "count": 13}, {"cid": 47, "color": "#FF9DA7", "label": "SmsReportTests", "count": 39}, {"cid": 48, "color": "#9C755F", "label": "write_tree", "count": 14}, {"cid": 49, "color": "#BAB0AC", "label": "CustomerInfoCardUITests", "count": 13}, {"cid": 50, "color": "#4E79A7", "label": "TestCase", "count": 10}, {"cid": 51, "color": "#F28E2B", "label": "Phone Cleanup Guide", "count": 12}, {"cid": 52, "color": "#E15759", "label": "Customer", "count": 47}, {"cid": 53, "color": "#76B7B2", "label": "project_tree.py", "count": 13}, {"cid": 54, "color": "#59A14F", "label": "Agent Operating Guidelines", "count": 11}, {"cid": 55, "color": "#EDC948", "label": "Command", "count": 28}, {"cid": 56, "color": "#B07AA1", "label": "BehinSMSProvider", "count": 8}, {"cid": 57, "color": "#FF9DA7", "label": "activate_sms", "count": 15}, {"cid": 58, "color": "#9C755F", "label": "Financial Data Objects", "count": 10}, {"cid": 59, "color": "#BAB0AC", "label": "Hybrid Migration Command", "count": 10}, {"cid": 60, "color": "#4E79A7", "label": ".run_cmd", "count": 37}, {"cid": 61, "color": "#F28E2B", "label": "Resource Cleanup Handler", "count": 10}, {"cid": 62, "color": "#E15759", "label": "SMS Timing Migration", "count": 10}, {"cid": 63, "color": "#76B7B2", "label": "admin_panel/templates/admin_panel/bedehi.html", "count": 10}, {"cid": 64, "color": "#59A14F", "label": "customerIDFactorBackup.html", "count":Line truncated
const RAW_NODES = [{"id": "tests_test_sms_autosendtests", "label": "AutoSendTests", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 13.2, "font": {"size": 0, "color": "#ffffff"}, "title": "AutoSendTests", "community": 1, "community_name": "AutoSendTests", "source_file": "tests/test_sms.py", "file_type": "code", "degree": 12}, {"id": "admin_panel_migrations_0001_initial_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0001_initial.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0007_productinstore_factor_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0007_productinstore_factor.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0008_storeroom_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0008_storeroom_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0011_storeroom_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0011_storeroom_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0018_alter_factoriteam_product_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0018_alter_factoriteam_product.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0019_article_articletype_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0019_article_articletype.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0036_sarresid_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0036_sarresid.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0038_auto_20240213_1735_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0038_auto_20240213_1735.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0042_article_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0042_article_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0046_auto_20260624_1818_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0046_auto_20260624_1818.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0054_auto_20260715_2250_migration", "label": "Migration", "color": {"background": "#4E79A7", "borderLine truncated
const RAW_EDGES = [{"from": "tests_test_sms", "to": "tests_test_sms_autosendtests", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_paneltestcase", "label": "inherits", "title": "inherits [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_overdue_visit_customer", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_default_is_pending_so_manager_must_approve", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_auto_send_enqueues_approved_without_manager", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_auto_send_does_not_bypass_opt_out_or_disabled", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_welcome_auto_send_skips_approval_on_customer_creation", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_template_edit_persists_auto_send", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_templates_page_shows_auto_send_switch", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_rationale_1246", "to": "tests_test_sms_autosendtests", "label": "rationale_for", "title": "rationale_for [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "admin_models_phonenumber", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "tests_test_sms_autosendtests", "to": "admin_models_customer", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "admin_panel_migrations_0001_initial", "to": "admin_panel_migrations_0001_initial_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0007_productinstore_factor", "to": "admin_panel_migrations_0007_productinstore_factor_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0008_storeroom_user", "to": "admin_panel_migrations_0008_storeroom_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0011_storeroom_user", "to": "admin_panel_migrations_0011_storeroom_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0018_alter_factoriteam_product", "to": "admin_panel_migrations_0018_alter_factoriteam_product_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0019_article_articletype", "to": "admin_panel_migrations_0019_article_articletype_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0036_sarresid", "to": "admin_panel_migrations_0036_sarresid_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0038_auto_20240213_1735", "to": "admin_panel_migrations_0038_auto_20240213_1735_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0042_article_user", "to": Line truncated
const LEGEND = [{"cid": 0, "color": "#4E79A7", "label": "views/sms.py", "count": 37}, {"cid": 1, "color": "#F28E2B", "label": "AutoSendTests", "count": 9}, {"cid": 2, "color": "#E15759", "label": "django_db", "count": 57}, {"cid": 3, "color": "#76B7B2", "label": "views/store.py", "count": 72}, {"cid": 4, "color": "#59A14F", "label": "ModelsPackageContractTests", "count": 15}, {"cid": 5, "color": "#EDC948", "label": "PhoneCleanupTests", "count": 35}, {"cid": 6, "color": "#B07AA1", "label": ".load", "count": 12}, {"cid": 7, "color": "#FF9DA7", "label": "models/sms.py", "count": 32}, {"cid": 8, "color": "#9C755F", "label": "django_db_models", "count": 27}, {"cid": 9, "color": "#BAB0AC", "label": "views/customers.py", "count": 29}, {"cid": 10, "color": "#4E79A7", "label": "django_db_models_deletion", "count": 27}, {"cid": 11, "color": "#F28E2B", "label": "PanelTestCase", "count": 14}, {"cid": 12, "color": "#E15759", "label": "mark_primary", "count": 16}, {"cid": 13, "color": "#76B7B2", "label": "EventTriggerTests", "count": 21}, {"cid": 14, "color": "#59A14F", "label": "views/finance.py", "count": 27}, {"cid": 15, "color": "#EDC948", "label": "django_conf", "count": 28}, {"cid": 16, "color": "#B07AA1", "label": "sms0098ws.py", "count": 22}, {"cid": 17, "color": "#FF9DA7", "label": "Admin Panel Customization", "count": 22}, {"cid": 18, "color": "#9C755F", "label": "views/actions.py", "count": 39}, {"cid": 19, "color": "#BAB0AC", "label": "timing_for", "count": 20}, {"cid": 20, "color": "#4E79A7", "label": "InactiveCustomerHiddenTests", "count": 16}, {"cid": 21, "color": "#F28E2B", "label": "normalize_mobile", "count": 35}, {"cid": 22, "color": "#E15759", "label": "Login Template Forms", "count": 19}, {"cid": 23, "color": "#76B7B2", "label": "cheque_inquiry.py", "count": 19}, {"cid": 24, "color": "#59A14F", "label": "factories.py", "count": 19}, {"cid": 25, "color": "#EDC948", "label": "Rip&#x27;n Fast. Fewer Tokens. Better Code.", "count": 19}, {"cid": 26, "color": "#B07AA1", "label": "views/factors.py", "count": 21}, {"cid": 27, "color": "#FF9DA7", "label": "SMSResult", "count": 18}, {"cid": 28, "color": "#9C755F", "label": "make_user", "count": 23}, {"cid": 29, "color": "#BAB0AC", "label": "QueueReviewTests", "count": 9}, {"cid": 30, "color": "#4E79A7", "label": "NoticeMechanismTests", "count": 16}, {"cid": 31, "color": "#F28E2B", "label": "name_fix.py", "count": 27}, {"cid": 32, "color": "#E15759", "label": "scanner.py", "count": 28}, {"cid": 33, "color": "#76B7B2", "label": "ZeroZeroNinetyEightWebServiceTests", "count": 16}, {"cid": 34, "color": "#59A14F", "label": "ScannerTests", "count": 22}, {"cid": 35, "color": "#EDC948", "label": "admin_panel/templates/admin_panel/baseHesabdari.html", "count": 15}, {"cid": 36, "color": "#B07AA1", "label": "Reference guide", "count": 15}, {"cid": 37, "color": "#FF9DA7", "label": "Factor Creation UI", "count": 14}, {"cid": 38, "color": "#9C755F", "label": "hesabdariResidNashode.html", "count": 14}, {"cid": 39, "color": "#BAB0AC", "label": "requests", "count": 22}, {"cid": 40, "color": "#4E79A7", "label": "CustomerSmsHistoryTests", "count": 35}, {"cid": 41, "color": "#F28E2B", "label": "TemplatesPageUITests", "count": 14}, {"cid": 42, "color": "#E15759", "label": "SMS Integration Docs", "count": 13}, {"cid": 43, "color": "#76B7B2", "label": "enqueue.py", "count": 13}, {"cid": 44, "color": "#59A14F", "label": "django_shortcuts", "count": 12}, {"cid": 45, "color": "#EDC948", "label": "stock_in", "count": 27}, {"cid": 46, "color": "#B07AA1", "label": "admin_panel/templates/admin_panel/customerAction.html", "count": 13}, {"cid": 47, "color": "#FF9DA7", "label": "SmsReportTests", "count": 39}, {"cid": 48, "color": "#9C755F", "label": "generate_snapshot", "count": 8}, {"cid": 49, "color": "#BAB0AC", "label": "CustomerInfoCardUITests", "count": 13}, {"cid": 50, "color": "#4E79A7", "label": "enqueue_sms", "count": 12}, {"cid": 51, "color": "#F28E2B", "label": "Phone Cleanup Guide", "count": 12}, {"cid": 52, "color": "#E15759", "label": ".run_command", "count": 39}, {"cid": 53, "color": "#76B7B2", "label": "project_tree.py", "count": 13}, {"cid": 54, "color": "#59A14F", "label": "Agent Operating Guidelines", "count": 11}, {"cid": 55, "color": "#EDC948", "label": "Command", "count": 28}, {"cid": 56, "color": "#B07AA1", "label": "BehinSMSProvider", "count": 8}, {"cid": 57, "color": "#FF9DA7", "label": "run_all", "count": 16}, {"cid": 58, "color": "#9C755F", "label": "Financial Data Objects", "count": 10}, {"cid": 59, "color": "#BAB0AC", "label": "Hybrid Migration Command", "count": 10}, {"cid": 60, "color": "#4E79A7", "label": "PhoneNumber", "count": 34}, {"cid": 61, "color": "#F28E2B", "label": "Resource Cleanup Handler", "count": 10}, {"cid": 62, "color": "#E15759", "label": "SMS Timing Migration", "count": 10}, {"cid": 63, "color": "#76B7B2", "label": "admin_panel/templates/admin_panel/bedehi.html", "count": 10}, {"cid": 64, "color": "#59A14F", "label": "customerIDFactorBackup.htLine truncated
// HTML-escape helper — prevents XSS when injecting graph data into innerHTML
function esc(s) {
+28128 -7625
View File
File diff suppressed because it is too large. Load diff
+113 -71
View File
@@ -102,10 +102,10 @@
"semantic_hash": "a93adc1bf01ae69281bc8736f254f7cc"
},
"admin_panel/management/commands/send_daily_report.py": {
"mtime": 1787309060.0288901,
"seen": 1791552045.91651,
"ast_hash": "acb098dbb2d4f61659f7d291b8ababbb",
"semantic_hash": "acb098dbb2d4f61659f7d291b8ababbb"
"mtime": 1792586144.6687672,
"seen": 1792649353.9243636,
"ast_hash": "e188d3f8d5a903e9e02801a1b96cfd12",
"semantic_hash": ""
},
"admin_panel/management/commands/sqlite_db_backup.py": {
"mtime": 1782242044.4963484,
@@ -468,10 +468,10 @@
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"admin_panel/templatetags/custom_tags.py": {
"mtime": 1791641617.9080276,
"seen": 1791702801.3156137,
"ast_hash": "849cd0b0dc695c9b16de2f5af51d1166",
"semantic_hash": "849cd0b0dc695c9b16de2f5af51d1166"
"mtime": 1792520052.030276,
"seen": 1792649353.9359002,
"ast_hash": "588a8144d71eaa0895965910d8270746",
"semantic_hash": ""
},
"admin_panel/templatetags/remove_q_p.py": {
"mtime": 1683307703.0,
@@ -486,9 +486,9 @@
"semantic_hash": "e08f0582500f6562bf0a931ef9503b39"
},
"admin_panel/urls.py": {
"mtime": 1791717588.9114156,
"seen": 1791720362.5829337,
"ast_hash": "5b14236a71f471fee84008cb051566b9",
"mtime": 1792585999.6085472,
"seen": 1792649353.9359002,
"ast_hash": "2b32b37ee1cb3d66df4f77ec71c18202",
"semantic_hash": ""
},
"check_finance.py": {
@@ -528,10 +528,10 @@
"semantic_hash": "73a68e0d30f37dc4fbdad316c02b5fc4"
},
"khatibi/settings.py": {
"mtime": 1791564345.9580202,
"seen": 1791702801.3183815,
"ast_hash": "b3f5a8c5895df16b918fc132147d8859",
"semantic_hash": "b3f5a8c5895df16b918fc132147d8859"
"mtime": 1792648856.7794306,
"seen": 1792649353.9429948,
"ast_hash": "1e701cd7ce0da81c3b7b06905e081a3b",
"semantic_hash": ""
},
"khatibi/urls.py": {
"mtime": 1650778147.0,
@@ -576,16 +576,16 @@
"semantic_hash": "4ba55e04b37e585d96f0d628c695d228"
},
"admin_panel/templates/admin_panel/base.html": {
"mtime": 1791719559.991003,
"seen": 1791720362.5829337,
"ast_hash": "2a59c1f5b13d05f75c5dd56b20c228ce",
"mtime": 1792579166.964455,
"seen": 1792649353.9462168,
"ast_hash": "cb15bf2ddc33194ff213da2df5d560ba",
"semantic_hash": ""
},
"admin_panel/templates/admin_panel/baseHesabdari.html": {
"mtime": 1703841590.0,
"seen": 1791553187.9024515,
"ast_hash": "47b9ea8bc69fc1731d0dbe267f1ed433",
"semantic_hash": "47b9ea8bc69fc1731d0dbe267f1ed433"
"mtime": 1792578927.2750068,
"seen": 1792649353.9462168,
"ast_hash": "7cf4f6d71fdaef9c03fd883be7592ee9",
"semantic_hash": ""
},
"admin_panel/templates/admin_panel/baseStore.html": {
"mtime": 1783083554.0144844,
@@ -606,16 +606,16 @@
"semantic_hash": "9f714287989d56c19bc1fc28a08cd89d"
},
"admin_panel/templates/admin_panel/customerAction.html": {
"mtime": 1784913239.8701046,
"seen": 1791553187.9024515,
"ast_hash": "bb1af41ef956a5e2f30b1266c70a30ee",
"semantic_hash": "bb1af41ef956a5e2f30b1266c70a30ee"
"mtime": 1792586046.4354496,
"seen": 1792649353.946735,
"ast_hash": "640f55bfa16cbfa34769900cc60ac813",
"semantic_hash": ""
},
"admin_panel/templates/admin_panel/customerActionInc.html": {
"mtime": 1784913846.5075295,
"seen": 1791552251.327148,
"ast_hash": "3232cbf82efe94504f69735791ca5cf4",
"semantic_hash": "3232cbf82efe94504f69735791ca5cf4"
"mtime": 1792587674.6419835,
"seen": 1792649353.946735,
"ast_hash": "31d5d41b4dffc8d860eeb67eeac1269c",
"semantic_hash": ""
},
"admin_panel/templates/admin_panel/customerBase.html": {
"mtime": 1791712775.7233438,
@@ -732,10 +732,10 @@
"semantic_hash": "c3912ead765eb7b5ada5634b70ebc644"
},
"admin_panel/templates/admin_panel/entezar.html": {
"mtime": 1708088349.0,
"seen": 1791553187.904458,
"ast_hash": "808fd766dd0d0abec11bfcccd57dcee2",
"semantic_hash": "808fd766dd0d0abec11bfcccd57dcee2"
"mtime": 1792582443.9341311,
"seen": 1792649353.9499876,
"ast_hash": "48f3f9627a23892e1af9c44cd0ccc47e",
"semantic_hash": ""
},
"admin_panel/templates/admin_panel/entezarBK.html": {
"mtime": 1650778147.0,
@@ -882,16 +882,16 @@
"semantic_hash": "7cd6b8258d07f9d1a4f668a95b1c9c58"
},
"admin_panel/models/actions.py": {
"mtime": 1792153042.089988,
"seen": 1792154233.652624,
"ast_hash": "5b305ef4bef23a1e8b518282ef1b318e",
"mtime": 1792585889.4524539,
"seen": 1792649353.9338899,
"ast_hash": "e8e9b985b43e33cce542ebdf0a3089d0",
"semantic_hash": ""
},
"admin_panel/models/customers.py": {
"mtime": 1791621708.4566169,
"seen": 1791702801.313386,
"ast_hash": "b70fa9395bb5cacb452b84cf0a794efe",
"semantic_hash": "b70fa9395bb5cacb452b84cf0a794efe"
"mtime": 1792519478.2847924,
"seen": 1792649353.9338899,
"ast_hash": "fe636b23d2edbbc5f53e389dc31349b5",
"semantic_hash": ""
},
"admin_panel/models/factors.py": {
"mtime": 1791557664.8430376,
@@ -942,9 +942,9 @@
"semantic_hash": "4412599acacb9e21ffa7a2d2748d9484"
},
"admin_panel/views/actions.py": {
"mtime": 1791708825.7957535,
"seen": 1791710591.5772536,
"ast_hash": "e0f571ab730d0c1de7a17c8e908f161d",
"mtime": 1792586094.5941224,
"seen": 1792649353.9394238,
"ast_hash": "3f97823898fe7664006d2504e5674c10",
"semantic_hash": ""
},
"admin_panel/views/auth.py": {
@@ -960,9 +960,9 @@
"semantic_hash": "364f4190f934261a082c189d8f44d29c"
},
"admin_panel/views/customers.py": {
"mtime": 1792159253.0096061,
"seen": 1792493391.7541184,
"ast_hash": "a4371b66153ff8afa68452aa35026d84",
"mtime": 1792582506.8527822,
"seen": 1792649353.9394238,
"ast_hash": "c7732ee79f069c4afde6f257988f347a",
"semantic_hash": ""
},
"admin_panel/views/factors.py": {
@@ -972,9 +972,9 @@
"semantic_hash": ""
},
"admin_panel/views/finance.py": {
"mtime": 1791713964.8948607,
"seen": 1791714098.633808,
"ast_hash": "d199e23a302ef9eee93c9abf459bc511",
"mtime": 1792586136.7521703,
"seen": 1792649353.9399738,
"ast_hash": "4ff50b8360753656a3ee111e4625177c",
"semantic_hash": ""
},
"admin_panel/views/store.py": {
@@ -1026,15 +1026,15 @@
"semantic_hash": "55662ec48c0ef4b683a87b94808b91b0"
},
"tests/test_urls_contract.py": {
"mtime": 1791718031.9609113,
"seen": 1791720362.5829337,
"ast_hash": "e02361bf950d2549018c1b19fad366bb",
"mtime": 1792586443.293034,
"seen": 1792649353.9446063,
"ast_hash": "d6e6748f0f07110252db17bac5c39990",
"semantic_hash": ""
},
"tests/test_view_smoke.py": {
"mtime": 1791718041.2553785,
"seen": 1791720362.5829337,
"ast_hash": "527f0f3fcffdddd54cf8db80ed21ce2b",
"mtime": 1792579100.7355275,
"seen": 1792649353.9446063,
"ast_hash": "19aa0fd08551d4bcc78d7a8afa9244de",
"semantic_hash": ""
},
"AGENTS.md": {
@@ -1146,9 +1146,9 @@
"semantic_hash": ""
},
"admin_panel/sms/scanner.py": {
"mtime": 1792157807.5642593,
"seen": 1792493391.7505708,
"ast_hash": "f03e830b2c7a3344fb2ed18acfeff919",
"mtime": 1792505981.4115465,
"seen": 1792649353.9359002,
"ast_hash": "3955e128c54b7abe80c417762e5db71a",
"semantic_hash": ""
},
"admin_panel/sms/timing.py": {
@@ -1158,9 +1158,9 @@
"semantic_hash": ""
},
"admin_panel/views/sms.py": {
"mtime": 1792159340.4347801,
"seen": 1792493391.7541184,
"ast_hash": "9aa47348420d891e99ca67401c677898",
"mtime": 1792586159.7053664,
"seen": 1792649353.9399738,
"ast_hash": "3ec750b036718c52e9bf16f8c6279c2b",
"semantic_hash": ""
},
"integrations/__init__.py": {
@@ -1260,9 +1260,9 @@
"semantic_hash": "d181c7e91c9fc824e45ee36afe852884"
},
"tests/test_sms.py": {
"mtime": 1792159900.5047233,
"seen": 1792493391.763808,
"ast_hash": "e1b1259ac6fd3522b73f30f4b8af04f8",
"mtime": 1792502301.0777557,
"seen": 1792649353.9446063,
"ast_hash": "3a1a97a8b09f6dc4e49b664f88d7773f",
"semantic_hash": ""
},
"admin_panel/templates/admin_panel/_customer_sms.html": {
@@ -1428,9 +1428,9 @@
"semantic_hash": ""
},
"admin_panel/management/commands/recalc_last_bazdid.py": {
"mtime": 1791716465.206951,
"seen": 1791720362.5829337,
"ast_hash": "72df3ebb464b770d9ca91ddc2ca32404",
"mtime": 1792499351.323715,
"seen": 1792649353.9243636,
"ast_hash": "7a6bc5270e1a61924101a46475902d9a",
"semantic_hash": ""
},
"admin_panel/migrations/0071_sms_settings_permission.py": {
@@ -1482,9 +1482,9 @@
"semantic_hash": ""
},
"tests/test_customer_name_fix.py": {
"mtime": 1792044607.9337437,
"seen": 1792143069.5209367,
"ast_hash": "9be8b6950f0393905fece6a5ec39d3fe",
"mtime": 1792520434.1397984,
"seen": 1792649353.944064,
"ast_hash": "09365bdda5e0a588028cc337b380f09d",
"semantic_hash": ""
},
"tests/test_sms_phone_normalize.py": {
@@ -1540,5 +1540,47 @@
"seen": 1792493391.7481978,
"ast_hash": "60c36802c8ec0752be3effd8cbc63e68",
"semantic_hash": ""
},
"admin_panel/management/commands/cleanup_visit_dedup.py": {
"mtime": 1792502665.8561392,
"seen": 1792649353.922272,
"ast_hash": "76d9611639bb1da68a9861db9d44518d",
"semantic_hash": ""
},
"admin_panel/migrations/0078_action_visit_covering_index.py": {
"mtime": 1792507867.8950336,
"seen": 1792649353.9338899,
"ast_hash": "e8dec5026122931759a139b8d3405d07",
"semantic_hash": ""
},
"admin_panel/migrations/0079_customer_visit_hold.py": {
"mtime": 1792519672.2225547,
"seen": 1792649353.9338899,
"ast_hash": "a857d48dbb7cc395dc3b2a3cbcb1968a",
"semantic_hash": ""
},
"admin_panel/migrations/0080_action_soft_delete.py": {
"mtime": 1792585898.3564234,
"seen": 1792649353.9338899,
"ast_hash": "a2270def509f83cfb982c1b3f98b576e",
"semantic_hash": ""
},
"admin_panel/visit_policy.py": {
"mtime": 1792585952.436753,
"seen": 1792649353.9399738,
"ast_hash": "91324d6f9f5f1e6a1904e718c7a13276",
"semantic_hash": ""
},
"tests/test_visit_policy.py": {
"mtime": 1792587539.4103932,
"seen": 1792649353.9446063,
"ast_hash": "03d0edf01f9a31f61525a609a2c516de",
"semantic_hash": ""
},
"docs/visit_hold_entezar.md": {
"mtime": 1792586615.9454145,
"seen": 1792649353.9525056,
"ast_hash": "7d81ac202f843e25caac8745ff7d6c20",
"semantic_hash": ""
}
}
+1 -1
View File
@@ -78,7 +78,7 @@ WSGI_APPLICATION = 'khatibi.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'data9/khatibi92669266_backups_db_backup_20261020_223011.sqlite3',
'NAME': BASE_DIR / 'data9/khatibi92669266_backups_db_backup_20261021_223009.sqlite3',
}
}
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python
"""شبیه‌سازیِ «تعویقِ کوتاه‌تر از تاریخ انجامِ هماهنگی» روی دیتابیس واقعی (کپی).
سناریوی گزارش‌شده: در فرم «ایجاد وظیفه» نوع = هماهنگی،
«تاریخ و ساعتی که کار باید انجام شود» = ۱۴۰۵/۰۸/۰۵ و
«تا تاریخ پیگیری نشود» = ۱۴۰۵/۰۸/۰۳ (یعنی قرار **قبل‌تر** از تاریخ کار).
این اسکریپت روی یک **کپی موقت** از دیتابیس، یک مشتری آزمایشی می‌سازد و رفتار
سیستم را روز‌به‌روز چاپ می‌کند: منابع قرار، فهرست «نیازمند پیگیری»، «عقب‌افتاده»،
و اینکه اسکنر پیامک در هر روز چه می‌کند. دیتابیس اصلی دست‌نخورده می‌ماند.
اجرا::
.venv/Scripts/python.exe scripts/coordination_hold_order_probe.py
.venv/Scripts/python.exe scripts/coordination_hold_order_probe.py --customer 5124
"""
import argparse
import datetime
import os
import shutil
import sys
import tempfile
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if BASE_DIR not in sys.path:
sys.path.insert(0, BASE_DIR)
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'khatibi.settings')
def _copy_db(source_path, target_dir):
target = os.path.join(target_dir, 'probe.sqlite3')
shutil.copy2(source_path, target)
for suffix in ('-wal', '-shm'):
side = source_path + suffix
if os.path.exists(side):
shutil.copy2(side, target + suffix)
return target
def _day(offset):
return datetime.datetime.combine(
datetime.date.today() + datetime.timedelta(days=offset), datetime.time(12, 0),
)
def run_variant(label, *, done, hold_offset, action_offset):
"""یک سناریو را می‌سازد و رفتار روز‌به‌روز را چاپ می‌کند."""
from django.utils import timezone
from admin_panel.models import (Action, Customer, PhoneNumber, SMSProviderConfig,
SMSQueue)
from admin_panel.sms.scanner import scan_visit
from admin_panel.visit_policy import (appointment_sources_map, end_of_local_day,
followups_on_date, last_visit_map,
manual_holds_map, next_appointment_map,
overdue_followups, set_visit_hold)
tz = timezone.get_current_timezone()
now = timezone.now()
today = now.astimezone(tz).date()
customer = Customer.objects.create(
name_family='مشتری آزمایشی — %s' % label, address='آزمایش',
)
Customer.objects.filter(pk=customer.pk).update(
installDate=today - datetime.timedelta(days=400))
PhoneNumber.objects.create(customer=customer, phone_number='09120000777')
customer.refresh_from_db()
action_dt = timezone.make_aware(_day(action_offset))
hold_raw = timezone.make_aware(_day(hold_offset)) if hold_offset is not None else None
action = Action.objects.create(
customer=customer, actionType=Action.ActionType.HAMAHANGI,
status=Action.Status.FINISH if done else Action.Status.WAITE,
actionDateTime=action_dt,
finishDateTime=now if done else None,
desc='probe', result='probe' if done else None,
)
# همان مسیری که ویو طی می‌کند (شامل پیش‌فرض ۱۴ روز و «فقط رو به جلو»).
from admin_panel.views.actions import _apply_visit_hold
_apply_visit_hold(
customer,
{'actionType': 'hamahangi', 'holdRaw': hold_raw, 'status': action.status},
status=action.status, is_new=True,
)
customer.refresh_from_db()
config = SMSProviderConfig.load()
config.scan_since = now - datetime.timedelta(days=600)
config.allow_backlog = True
config.backlog_max = 50
config.max_per_run = 200
config.save()
print('\n' + '=' * 100)
print('سناریو %s' % label)
print(' تیک «انجام شد»: %s → status=%s' % ('خورده' if done else 'نخورده', action.status))
print(' تاریخ کار (actionDateTime): %s' % action.actionDateTime.astimezone(tz))
print(' تا تاریخ پیگیری نشود: %s' % (
customer.visit_hold_until.astimezone(tz) if customer.visit_hold_until
else 'خالی (هیچ تعویقی ثبت نشد)'))
# اثرهای جانبی «تیک انجام شد»: لیست وظایف، شمارندهٔ داشبورد، نظرسنجی.
print(' در «لیست وظایف باز» (ActionList/داشبورد): %s' % (
'بله' if action.status == Action.Status.WAITE else 'خیر'))
survey = (action.status == Action.Status.FINISH
and action.actionType != 'vosol'
and action.nazarDT is None
and action.createdDT >= now - datetime.timedelta(days=60)
and (action.finishDateTime is None
or action.finishDateTime >= now - datetime.timedelta(days=30)))
print(' در صف «نظرسنجی»: %s' % ('بله' if survey else 'خیر'))
# اسکن کامل دیتابیس گران است؛ تصمیمِ اسکنر برای همین مشتری بازسازی می‌شود:
# scanner.py:232 if customer.id in appointments: continue
# scanner.py:240 trigger = visit_latest_due(anchor, now=now)
from admin_panel.visit_policy import cycle_anchor_map, visit_latest_due
def scanner_decision(moment):
appointments = next_appointment_map(now=moment)
if customer.pk in appointments:
return 'رد (قرار بعدی دارد)'
anchors = cycle_anchor_map(now=moment)
anchor = anchors.get(customer.pk)
if anchor is None:
return 'رد (لنگر ندارد)'
trigger = visit_latest_due(anchor, now=moment)
if trigger is None or trigger.date() > moment.astimezone(tz).date():
return 'رد (موعد نرسیده)'
return 'ارسال یادآوری دوره‌ای'
for day_offset in range(0, action_offset + 3):
moment = timezone.make_aware(_day(day_offset))
sources = appointment_sources_map(now=moment)
nexts = next_appointment_map(now=moment)
holds = manual_holds_map(now=moment)
entries = dict(sources.get(customer.pk) or {})
day = moment.astimezone(tz).date()
follow = followups_on_date(day, now=moment)
overdue = overdue_followups(now=moment, target_date=day)
visited = last_visit_map(now=moment, customer_ids=[customer.pk])
badge_days = {value.astimezone(tz).date() for value in entries.values()}
scan_cell = scanner_decision(moment)
print(' %s | منابع=%-34s برچسب=%d قرار | قرار بعدی=%-11s | پیگیری=%s'
' | عقب‌افتاده=%s | قرار دستی=%s | %s' % (
day, '+'.join(sorted(entries)) or '—', len(badge_days),
(nexts.get(customer.pk).astimezone(tz).date()
if nexts.get(customer.pk) else '—'),
'بله' if customer.pk in follow else 'خیر',
'بله' if customer.pk in overdue else 'خیر',
'بله' if customer.pk in holds else 'خیر',
scan_cell,
))
_ = visited
SMSQueue.objects.filter(customer=customer).delete()
Action.objects.filter(customer=customer).delete()
def show_customer(customer_id):
"""وضعیت فعلی یک مشتری واقعی + خط زمانی اقدام‌هایش (فقط خواندن)."""
from django.utils import timezone
from admin_panel.models import Action, Customer
from admin_panel.visit_policy import (appointment_sources_map, last_visit_map,
manual_holds_map, next_appointment_map)
tz = timezone.get_current_timezone()
customer = Customer.objects.filter(pk=customer_id).first()
print('\n' + '=' * 100)
print('وضعیت مشتری #%s' % customer_id)
if customer is None:
print(' مشتری پیدا نشد.')
return
print(' نام: %s | فعال: %s | لغو بازدید: %s' % (
customer.name_family, customer.is_active, customer.laghv_bazdid))
print(' visit_hold_until: %s' % customer.visit_hold_until)
sources = appointment_sources_map()
print(' منابع قرار: %s' % {
k: str(v.astimezone(tz)) for k, v in (sources.get(customer.pk) or {}).items()})
print(' قرار بعدی: %s' % next_appointment_map().get(customer.pk))
print(' قرار دستی معتبر: %s' % (customer.pk in manual_holds_map()))
print(' آخرین بازدید: %s' % last_visit_map().get(customer.pk))
print(' اقدام‌ها (۱۰ مورد آخر):')
for act in Action.objects.filter(customer_id=customer_id).order_by('-createdDT')[:10]:
print(' #%-6s %-10s %-7s action=%-19s finish=%-19s created=%s' % (
act.pk, act.actionType, act.status,
act.actionDateTime, act.finishDateTime, act.createdDT))
def main():
parser = argparse.ArgumentParser(description='شبیه‌سازی تعویقِ قبل از تاریخ کار')
parser.add_argument('--customer', type=int, default=None, help='شناسهٔ مشتری واقعی')
parser.add_argument('--skip-variants', action='store_true')
args = parser.parse_args()
import django
django.setup()
from django.conf import settings
from django.db import connections
source_db = str(settings.DATABASES['default']['NAME'])
if not os.path.isabs(source_db):
source_db = os.path.join(BASE_DIR, source_db)
tmp_dir = tempfile.mkdtemp(prefix='hold_probe_')
settings.DATABASES['default']['NAME'] = _copy_db(source_db, tmp_dir)
connections.close_all()
print('دیتابیسِ کپیِ موقت: %s' % settings.DATABASES['default']['NAME'])
if not args.skip_variants:
run_variant('بدون تیک «انجام شد»', done=False, hold_offset=3, action_offset=5)
run_variant('با تیک «انجام شد»', done=True, hold_offset=3, action_offset=5)
run_variant('تعویق بعد از تاریخ کار (سالم)', done=True, hold_offset=10,
action_offset=5)
run_variant('تعویق خالی + تیک (پیش‌فرض ۱۴ روز)', done=True, hold_offset=None,
action_offset=5)
run_variant('تعویق خالی + بدون تیک', done=False, hold_offset=None,
action_offset=5)
if args.customer:
show_customer(args.customer)
shutil.rmtree(tmp_dir, ignore_errors=True)
if __name__ == '__main__':
main()
+370
View File
@@ -0,0 +1,370 @@
#!/usr/bin/env python
"""ممیزیِ فقط-خواندنیِ رفتار «قرار / پیگیری» روی دیتابیس واقعی.
این اسکریپت هیچ نوشتنی روی دیتابیسِ ورودی انجام نمی‌دهد: ابتدا یک **کپی موقت**
از فایل sqlite (به‌همراه ``-wal``/``-shm``) می‌سازد و همهٔ کوئری‌ها و توابع
``admin_panel.visit_policy`` را روی همان کپی اجرا می‌کند.
اجرا::
.venv/Scripts/python.exe scripts/entezar_action_audit.py
.venv/Scripts/python.exe scripts/entezar_action_audit.py --db data9/xxx.sqlite3
.venv/Scripts/python.exe scripts/entezar_action_audit.py --samples 10
"""
import argparse
import os
import shutil
import sys
import tempfile
from collections import Counter
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if BASE_DIR not in sys.path:
sys.path.insert(0, BASE_DIR)
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'khatibi.settings')
def _copy_db(source_path, target_dir):
"""کپی امنِ dbfile + فایل‌های WAL در پوشهٔ موقت؛ مسیر کپی برگردانده می‌شود."""
target = os.path.join(target_dir, 'audit_copy.sqlite3')
shutil.copy2(source_path, target)
for suffix in ('-wal', '-shm'):
side = source_path + suffix
if os.path.exists(side):
shutil.copy2(side, target + suffix)
return target
def _aware(value, tz):
from django.utils import timezone
if value is None:
return None
if timezone.is_naive(value):
return timezone.make_aware(value, tz)
return value
def _local_date(value, tz):
value = _aware(value, tz)
return None if value is None else value.astimezone(tz).date()
def _pct(part, whole):
return 0.0 if not whole else round(100.0 * part / whole, 1)
def main():
parser = argparse.ArgumentParser(description='ممیزی فقط-خواندنی صفحهٔ انتظار')
parser.add_argument('--db', default=None, help='مسیر دیتابیس (پیش‌فرض: settings)')
parser.add_argument('--samples', type=int, default=5, help='تعداد نمونه در خروجی')
parser.add_argument('--customer', type=int, default=None,
help='شناسهٔ مشتری برای نمایش خط زمانی کامل اقدام‌ها')
args = parser.parse_args()
import django
django.setup()
from django.conf import settings
from django.utils import timezone
from admin_panel.models import Action, Customer
from admin_panel.visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL,
appointment_sources_map,
followups_on_date, last_visit_map,
overdue_followups)
source_db = args.db or str(settings.DATABASES['default']['NAME'])
if not os.path.isabs(source_db):
source_db = os.path.join(BASE_DIR, source_db)
if not os.path.exists(source_db):
raise SystemExit('دیتابیس پیدا نشد: %s' % source_db)
tmp_dir = tempfile.mkdtemp(prefix='entezar_audit_')
settings.DATABASES['default']['NAME'] = _copy_db(source_db, tmp_dir)
from django.db import connections
connections.close_all()
print('دیتابیسِ کپیِ موقت: %s' % settings.DATABASES['default']['NAME'])
now = timezone.now()
tz = timezone.get_current_timezone()
today = now.astimezone(tz).date()
# ---------------- داده‌های پایه ----------------
customers = Customer.objects.all().values_list(
'id', 'name_family', 'is_active', 'laghv_bazdid', 'visit_hold_until', 'installDate',
)
customer_rows = {row[0]: row for row in customers}
coord_rows = list(
Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI,
status__in=(Action.Status.WAITE, Action.Status.FINISH),
deleted_at__isnull=True,
).values_list('customer_id', 'id', 'actionDateTime', 'finishDateTime',
'createdDT', 'status')
)
visit_rows = list(
Action.objects.filter(
actionType=Action.ActionType.BAZDID,
status=Action.Status.FINISH,
).values_list('customer_id', 'id', 'actionDateTime', 'finishDateTime', 'createdDT')
)
pending_rows = list(
Action.objects.filter(
actionType=Action.ActionType.BAZDID,
status=Action.Status.WAITE,
).values_list('customer_id', 'id', 'actionDateTime', 'finishDateTime', 'createdDT')
)
print('مشتریان: %d | هماهنگی فعال: %d | بازدید انجام‌شده: %d | بازدید در انتظار: %d'
% (len(customer_rows), len(coord_rows), len(visit_rows), len(pending_rows)))
visits = last_visit_map(now=now)
sources = appointment_sources_map(now=now)
overdue = overdue_followups(now=now, target_date=today)
today_followups = followups_on_date(today, now=now)
last_coord = {}
for cid, aid, adt, fdt, created, status in coord_rows:
current = last_coord.get(cid)
created_aware = _aware(created, tz)
if current is None or created_aware > current['created']:
last_coord[cid] = {
'id': aid, 'actionDateTime': adt, 'finishDateTime': fdt,
'created': created_aware, 'status': status,
}
visits_by_customer = {}
for row in visit_rows:
visits_by_customer.setdefault(row[0], []).append(row)
def effective(dt, fdt):
"""قاعدهٔ ``effective_visit_dt``: actionDateTime اگر گذشته باشد، وگرنه finish."""
for value in (_aware(dt, tz), _aware(fdt, tz)):
if value is not None and value <= now:
return value
return None
def analyse(cid):
"""تشخیص علت حضور در «نیازمند پیگیری»."""
coord = last_coord.get(cid)
done = visits_by_customer.get(cid) or []
fact = {
'coord_day': _local_date(coord['actionDateTime'], tz) if coord else None,
'coord_created': coord['created'] if coord else None,
'coord_status': coord['status'] if coord else None,
'visits_total': len(done),
'visit_after_coord_created': False,
'visit_dt_after_coord_day': False,
'last_visit': visits.get(cid),
'last_visit_day': _local_date(visits.get(cid), tz),
}
if coord is not None:
for _cid, _aid, adt, fdt, created in done:
if _aware(created, tz) > coord['created']:
fact['visit_after_coord_created'] = True
eff = effective(adt, fdt)
if eff is not None and fact['coord_day'] is not None \
and eff.astimezone(tz).date() > fact['coord_day']:
fact['visit_dt_after_coord_day'] = True
return fact
# ---------------- ۱) نمای missed ----------------
print('\n=== ۱) ?view=today&missed=1 (پیگیری‌های عقب‌افتاده) ===')
missed_ids = [cid for cid in overdue if cid in customer_rows
and customer_rows[cid][2] and not customer_rows[cid][3]
and customer_rows[cid][1]]
print('تعداد ردیف: %d' % len(missed_ids))
reasons = Counter()
samples = []
facts = {}
for cid in missed_ids:
fact = facts[cid] = analyse(cid)
if fact['visits_total'] == 0:
reasons['بدون هیچ بازدید انجام‌شده'] += 1
elif fact['visit_after_coord_created']:
reasons['بازدید انجام‌شده پس از ثبت هماهنگی'] += 1
elif fact['visit_dt_after_coord_day']:
reasons['تاریخ بازدید بعد از موعد هماهنگی است'] += 1
else:
reasons['بازدید انجام‌شده اما قدیمی‌تر از هماهنگی'] += 1
if len(samples) < args.samples and fact['visits_total']:
samples.append((cid, customer_rows[cid][1], fact))
for label, count in reasons.most_common():
print(' - %-60s %6d (%s%%)' % (label, count, _pct(count, len(missed_ids))))
with_visit = sum(1 for f in facts.values() if f['visits_total'])
after_coord = sum(1 for f in facts.values() if f['visit_after_coord_created'])
last_visit_after = sum(
1 for f in facts.values()
if f['last_visit_day'] and f['coord_day'] and f['last_visit_day'] > f['coord_day']
)
print(' => دارای حداقل یک بازدید انجام‌شده: %d (%s%%)'
% (with_visit, _pct(with_visit, len(missed_ids))))
print(' => بازدید پس از ثبت هماهنگی: %d (%s%%)'
% (after_coord, _pct(after_coord, len(missed_ids))))
print(' => آخرین بازدید بعد از موعدِ هماهنگی: %d (%s%%)'
% (last_visit_after, _pct(last_visit_after, len(missed_ids))))
statuses = Counter(f['coord_status'] for f in facts.values())
print(' => وضعیت آخرین هماهنگی: %s' % dict(statuses))
finish_without_hold = sum(
1 for cid in missed_ids
if facts[cid]['coord_status'] == Action.Status.FINISH
and not (customer_rows[cid][4] and customer_rows[cid][4] > now)
)
print(' => هماهنگیِ finish بدون قرار دستیِ فعال: %d' % finish_without_hold)
pending_old = sum(1 for cid in missed_ids
if any(row[0] == cid for row in pending_rows))
print(' => دارای بازدید در انتظارِ قدیمی (قرار منقضی): %d' % pending_old)
holds_in_missed = sum(1 for cid in missed_ids
if customer_rows[cid][4] and customer_rows[cid][4] > now)
print(' => دارای «قرار دستی» آینده و باز هم در missed: %d' % holds_in_missed)
valid_holds_in_missed = sum(
1 for cid in missed_ids if SOURCE_MANUAL in (sources.get(cid) or {}))
print(' => دارای «قرار دستی» **معتبر** و باز هم در missed: %d'
% valid_holds_in_missed)
for cid, name, fact in samples:
print(' نمونه #%s %s | موعد هماهنگی: %s | وضعیت هماهنگی: %s | آخرین بازدید: %s'
% (cid, name, fact['coord_day'], fact['coord_status'], fact['last_visit_day']))
# ---------------- ۲) نمای today ----------------
print('\n=== ۲) ?view=today (نیازمند پیگیری امروز) ===')
eligible_today = [cid for cid in today_followups if cid in customer_rows
and customer_rows[cid][2] and not customer_rows[cid][3]
and customer_rows[cid][1]]
print('تعداد ردیف: %d' % len(eligible_today))
combo = Counter()
for cid in eligible_today:
entry = dict(sources.get(cid) or {})
entry.update(today_followups.get(cid) or {})
combo[tuple(sorted(entry))] += 1
print('ترکیب منابعِ «قرار» در این نما:')
for keys, count in combo.most_common():
print(' - %-45s %6d' % ('+'.join(keys) or 'بدون منبع', count))
today_facts = [analyse(cid) for cid in eligible_today]
done_today = sum(1 for f in today_facts if f['visits_total'])
done_after = sum(1 for f in today_facts if f['visit_after_coord_created'])
def followup_day(cid):
entry = today_followups.get(cid) or {}
return _local_date(min(entry.values()), tz) if entry else None
last_after = sum(
1 for cid, f in zip(eligible_today, today_facts)
if f['last_visit_day'] and followup_day(cid)
and f['last_visit_day'] >= followup_day(cid)
)
print(' => دارای بازدید انجام‌شده: %d (%s%%)'
% (done_today, _pct(done_today, len(eligible_today))))
print(' => بازدید پس از ثبت هماهنگی: %d (%s%%)'
% (done_after, _pct(done_after, len(eligible_today))))
print(' => آخرین بازدید در/بعد از همان روزِ موعد: %d' % last_after)
for cid, fact in list(zip(eligible_today, today_facts))[:args.samples or 0]:
print(' #%-6s %-28s موعد=%-12s آخرین بازدید=%-12s منابع=%s'
% (cid, customer_rows[cid][1], followup_day(cid),
fact['last_visit_day'], sorted(today_followups.get(cid) or {})))
# تفکیک موعدهای امروز: هماهنگیِ «انجام‌شده» (زمان تماس) یا قرار دستی؟
today_coord_status = Counter()
today_with_hold = 0
for cid in eligible_today:
if SOURCE_COORDINATION in (today_followups.get(cid) or {}):
coord = last_coord.get(cid)
today_coord_status[coord['status'] if coord else '?'] += 1
if SOURCE_MANUAL in (sources.get(cid) or {}):
today_with_hold += 1
print(' => وضعیت آخرین اقدام هماهنگی (ردیف‌های دارای منبع هماهنگی): %s'
% dict(today_coord_status))
print(' => از این ردیف‌ها، دارای قرار دستیِ آینده (یعنی بعداً هم می‌آید): %d'
% today_with_hold)
# ---------------- ۳) برچسب «N قرار» ----------------
print('\n=== ۳) برچسب وضعیت «N قرار» (source_count) ===')
source_count = Counter()
for cid, entry in sources.items():
if cid in customer_rows and customer_rows[cid][2]:
source_count[len(entry)] += 1
for n in sorted(source_count):
print(' %d منبع: %6d مشتری' % (n, source_count[n]))
pairs = Counter()
same_day = 0
different_day = 0
for cid, entry in sources.items():
if len(entry) > 1:
pairs['+'.join(sorted(entry))] += 1
if SOURCE_MANUAL in entry and SOURCE_COORDINATION in entry:
if _local_date(entry[SOURCE_MANUAL], tz) == _local_date(
entry[SOURCE_COORDINATION], tz):
same_day += 1
else:
different_day += 1
print(' از ترکیب manual+coordination: هم‌روز=%d | مختلف=%d'
% (same_day, different_day))
print(' ترکیب مشتریان با بیش از یک منبع:')
for keys, count in pairs.most_common():
print(' - %-45s %6d' % (keys, count))
for cid in [cid for cid, e in sources.items() if len(e) > 1][:args.samples or 0]:
print(' نمونه #%s %s: %s'
% (cid, customer_rows[cid][1],
{k: str(_local_date(v, tz)) for k, v in sources[cid].items()}))
# ---------------- ۴) ستون قرار دستی ----------------
print('\n=== ۴) ستون visit_hold_until («تا تاریخ پیگیری نشود») ===')
holds = [(cid, _aware(row[4], tz)) for cid, row in customer_rows.items() if row[4]]
future = [(cid, until) for cid, until in holds if until > now]
past = [(cid, until) for cid, until in holds if until <= now]
valid_future = sum(
1 for cid, _ in future
if SOURCE_MANUAL in (sources.get(cid) or {})
)
print('پرشده: %d | آینده: %d | گذشته (کهنه): %d | آینده و معتبر: %d'
% (len(holds), len(future), len(past), valid_future))
print(' => از %d قرارِ آینده، %d مورد بی‌اعتبار است (آخرین فعالیت هماهنگی نیست).'
% (len(future), len(future) - valid_future))
invalid_reason = Counter()
for cid, _until in future:
if cid not in last_coord:
invalid_reason['بدون اقدام هماهنگی فعال'] += 1
elif cid in visits_by_customer and any(
_aware(row[4], tz) > last_coord[cid]['created']
for row in visits_by_customer[cid]):
invalid_reason['بازدید ثبت‌شده بعد از هماهنگی'] += 1
elif SOURCE_MANUAL not in (sources.get(cid) or {}):
invalid_reason['سایر اقدام‌های بعد از هماهنگی'] += 1
print(' دلایل بی‌اعتباری: %s' % dict(invalid_reason))
# ---------------- ۵) هماهنگی ----------------
print('\n=== ۵) اقدام‌های هماهنگی ===')
print('وضعیت: %s' % dict(Counter(row[5] for row in coord_rows)))
with_hold_field = sum(1 for cid in last_coord
if customer_rows.get(cid) and customer_rows[cid][4])
print('مشتری با هماهنگی فعال و ستونِ قرار پرشده: %d' % with_hold_field)
# ---------------- ۶) خط زمانی یک مشتری ----------------
if args.customer:
cid = args.customer
row = customer_rows.get(cid)
print('\n=== ۶) خط زمانی مشتری #%s ===' % cid)
if row is None:
print(' مشتری پیدا نشد.')
else:
print(' نام: %s | فعال: %s | لغو بازدید: %s | قرار دستی: %s'
% (row[1], row[2], row[3], row[4]))
print(' در missed: %s | در today: %s | منابع: %s'
% (cid in overdue, cid in today_followups, sources.get(cid)))
for act in Action.objects.filter(customer_id=cid).order_by('createdDT'):
print(' #%-6s %-10s %-7s action=%-19s finish=%-19s created=%-19s deleted=%s'
% (act.pk, act.actionType, act.status,
_aware(act.actionDateTime, tz), _aware(act.finishDateTime, tz),
_aware(act.createdDT, tz), act.deleted_at is not None))
shutil.rmtree(tmp_dir, ignore_errors=True)
if __name__ == '__main__':
main()
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env python
"""صحت‌سنجیِ دو نقص گزارش‌شده دربارهٔ ``set_visit_hold`` (روی کپی دیتابیس).
سناریوها روی یک **کپی موقت** از دیتابیس اجرا می‌شوند؛ دیتابیس اصلی دست‌نخورده
می‌ماند و هیچ کد تولیدی‌ای تغییر نمی‌کند — این فقط ابزار اثبات/رد ادعاهاست.
اجرا::
.venv/Scripts/python.exe scripts/hold_write_verification.py
"""
import argparse
import datetime
import os
import shutil
import sys
import tempfile
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if BASE_DIR not in sys.path:
sys.path.insert(0, BASE_DIR)
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'khatibi.settings')
def _copy_db(source_path, target_dir):
target = os.path.join(target_dir, 'verify.sqlite3')
shutil.copy2(source_path, target)
for suffix in ('-wal', '-shm'):
side = source_path + suffix
if os.path.exists(side):
shutil.copy2(side, target + suffix)
return target
def _new_customer(name):
from django.utils import timezone
from admin_panel.models import Customer
today = timezone.localdate()
customer = Customer.objects.create(name_family=name, address='verify')
Customer.objects.filter(pk=customer.pk).update(
installDate=today - datetime.timedelta(days=400))
customer.refresh_from_db()
return customer
def _coordination(customer, *, days, status='finish'):
from django.utils import timezone
from admin_panel.models import Action
when = timezone.now() + datetime.timedelta(days=days)
return Action.objects.create(
customer=customer, actionType=Action.ActionType.HAMAHANGI, status=status,
actionDateTime=when,
finishDateTime=timezone.now() if status == 'finish' else None,
desc='verify',
)
def _show(label, customer):
from django.utils import timezone
from admin_panel.visit_policy import manual_holds_map, appointment_sources_map
tz = timezone.get_current_timezone()
print(' %-44s column=%-18s معتبر=%-5s منابع=%s' % (
label,
customer.visit_hold_until.astimezone(tz).strftime('%Y-%m-%d')
if customer.visit_hold_until else 'None',
'بله' if customer.pk in manual_holds_map() else 'خیر',
sorted((appointment_sources_map().get(customer.pk) or {}).keys()) or '—',
))
def claim_1_silent_failure():
"""نقص ۲ — آیا `_apply_visit_hold` نتیجه را نادیده می‌گیرد؟"""
from django.utils import timezone
from admin_panel.views.actions import _apply_visit_hold
from admin_panel.visit_policy import set_visit_hold
from admin_panel.models import Action
print('\n' + '=' * 92)
print('ادعای ۱ (نقص ۲): نتیجهٔ set_visit_hold در لایهٔ فراخوان دور ریخته می‌شود')
now = timezone.now()
customer = _new_customer('نقص۲')
set_visit_hold(customer, now + datetime.timedelta(days=20))
far = customer.visit_hold_until
_show('پس از ثبت قرار ۲۰ روزه', customer)
written = set_visit_hold(customer, now + datetime.timedelta(days=5))
print(' set_visit_hold(5 روز) ⇒ %s (مقدار بولی واقعی)' % written)
customer.refresh_from_db()
print(' ستون تغییر کرد؟ %s' % ('بله' if customer.visit_hold_until != far else 'خیر'))
result = _apply_visit_hold(
customer,
{'actionType': Action.ActionType.HAMAHANGI,
'holdRaw': now + datetime.timedelta(days=5),
'status': Action.Status.FINISH},
status=Action.Status.FINISH, is_new=False,
)
print(' _apply_visit_hold ⇒ %s' % result)
silent = result.get('reason') != 'forward_only'
print(' نتیجه: نقص ۲ %s' % ('**هنوز وجود دارد** (نتیجه گزارش نمی‌شود)'
if silent else '**رفع شد** (reason=forward_only گزارش می‌شود)'))
def claim_2_no_success_message():
"""آیا کاربر بازخورد می‌بیند؟ (پس از رفع: پیام هشدار با دلیل و تاریخ‌ها)"""
import inspect
from admin_panel.views import actions as views
print('\n' + '=' * 92)
print('بازخورد کاربر در مسیر ویرایش/ایجاد')
edit_src = inspect.getsource(views.customerEditAction)
create_src = inspect.getsource(views.customerNewAction)
helper_src = inspect.getsource(views._report_hold_result)
print(' customerEditAction نتیجهٔ قرار را گزارش می‌کند؟ %s'
% ('_report_hold_result' in edit_src))
print(' customerNewAction نتیجهٔ قرار را گزارش می‌کند؟ %s'
% ('_report_hold_result' in create_src))
print(' پیام هشدار شامل دلیل و تاریخ‌ها است؟ %s'
% ('ذخیره نشد' in helper_src))
print(' پیام موفقیتِ گمراه‌کننده وجود دارد؟ %s'
% ('messages.success' in edit_src or 'messages.success' in create_src))
def claim_3_stale_column_lock():
"""نقص ۳ — قفل شدن ستون بعد از حذف اقدام منبع."""
from django.utils import timezone
from admin_panel.visit_policy import (manual_holds_map, set_visit_hold,
soft_delete_action)
print('\n' + '=' * 92)
print('ادعای ۳ (نقص ۳): حذف اقدام منبع ستون را پاک نمی‌کند و نوشتن را قفل می‌کند')
now = timezone.now()
customer = _new_customer('نقص۳')
action = _coordination(customer, days=60)
set_visit_hold(customer, now + datetime.timedelta(days=60))
customer.refresh_from_db()
_show('قرار ۶۰ روزه ثبت شد', customer)
soft_delete_action(action)
action.refresh_from_db()
customer.refresh_from_db()
print(' اقدام منبع ⇒ status=%s deleted_at=%s' % (action.status, action.deleted_at))
_show('بعد از حذف (ستون کهنه ولی پر)', customer)
print(' در manual_holds_map هست؟ %s' % (customer.pk in manual_holds_map()))
ok = set_visit_hold(customer, now + datetime.timedelta(days=10))
customer.refresh_from_db()
print(' set_visit_hold(10 روز) ⇒ %s' % ok)
_show('پس از تلاش برای تاریخ نزدیک‌تر', customer)
print(' نتیجه: قفل %s' % ('تأیید می‌شود' if ok is False else 'رد می‌شود'))
def claim_4_escape_hatch():
"""آیا مسیر عادیِ رهاسازی وجود دارد؟ (ادعا: هیچ)"""
from django.utils import timezone
from admin_panel.models import Action
from admin_panel.views.actions import _apply_visit_hold
from admin_panel.visit_policy import set_visit_hold, soft_delete_action
print('\n' + '=' * 92)
print('ادعای ۴: «هیچ مسیر عادی‌ای برای رهاسازی وجود ندارد»')
now = timezone.now()
customer = _new_customer('رهاسازی')
action = _coordination(customer, days=60)
set_visit_hold(customer, now + datetime.timedelta(days=60))
soft_delete_action(action)
customer.refresh_from_db()
_show('قفل‌شده پس از حذف اقدام', customer)
# ثبت یک اقدام **غیرهماهنگی** جدید ⇒ is_new=True ⇒ clear_visit_hold
cleared = _apply_visit_hold(
customer,
{'actionType': Action.ActionType.OUT_CALL, 'holdRaw': None,
'status': Action.Status.WAITE},
status=Action.Status.WAITE, is_new=True,
)
customer.refresh_from_db()
print(' ثبت «تماس خروجی» جدید ⇒ clear_visit_hold: %s | ستون=%s'
% (cleared.get('reason') or cleared.get('ok'), customer.visit_hold_until))
ok = set_visit_hold(customer, now + datetime.timedelta(days=10))
customer.refresh_from_db()
_show('قرار ۱۰ روزه پس از رهاسازی', customer)
print(' ⇒ %s' % ('ادعا **رد** می‌شود: مسیر وجود دارد (ولی غیرشهودی و داده‌آلوده)'
if ok else 'ادعا تأیید می‌شود'))
def claim_5_edit_to_visit():
"""مسیری که در گزارش نیامده: ویرایش هماهنگی → «بازدید» ستون را پاک نمی‌کند."""
from django.utils import timezone
from admin_panel.models import Action
from admin_panel.views.actions import _apply_visit_hold
from admin_panel.visit_policy import set_visit_hold
print('\n' + '=' * 92)
print('یافتهٔ اضافی: ویرایش هماهنگی → «بازدید» ⇒ ستون می‌ماند ولی اعتبارش می‌رود')
now = timezone.now()
customer = _new_customer('ویرایش-به-بازدید')
action = _coordination(customer, days=60)
set_visit_hold(customer, now + datetime.timedelta(days=60))
customer.refresh_from_db()
action.actionType = Action.ActionType.BAZDID
action.status = Action.Status.FINISH
action.finishDateTime = now
action.save()
_apply_visit_hold(
customer,
{'actionType': Action.ActionType.BAZDID, 'holdRaw': None,
'status': Action.Status.FINISH},
status=Action.Status.FINISH, is_new=False,
)
customer.refresh_from_db()
_show('پس از ویرایش به بازدید', customer)
ok = set_visit_hold(customer, now + datetime.timedelta(days=7))
print(' set_visit_hold(7 روز) ⇒ %s' % ok)
print(' ⇒ %s' % ('قفل، حتی با ستونِ بی‌اعتبار — شدت نقص ۳ بیشتر از تخمین گزارش است'
if ok is False else 'قفل نشد'))
def real_db_stats():
"""آمار واقعی: چند مشتری ستون پرشدهٔ «بی‌اعتبار» دارند؟"""
from django.utils import timezone
from admin_panel.models import Action, Customer
from admin_panel.visit_policy import manual_holds_map
print('\n' + '=' * 92)
print('آمار دیتابیس واقعی (روی کپی)')
tz = timezone.get_current_timezone()
now = timezone.now()
future = list(Customer.objects.filter(visit_hold_until__gt=now))
valid = set(manual_holds_map())
dead = [c for c in future if c.pk not in valid]
print(' ستون پرشده و آینده: %d | معتبر: %d | بی‌اعتبار (قفل‌کننده): %d'
% (len(future), len(valid), len(dead)))
for c in dead[:10]:
last = Action.objects.filter(
customer=c, actionType=Action.ActionType.HAMAHANGI,
).order_by('-createdDT').first()
print(' id=%-6s idCode=%-10s hold=%-11s آخرین هماهنگی=%s (%s)' % (
c.id, c.idCode,
c.visit_hold_until.astimezone(tz).date(),
last.status if last else '—',
last.createdDT.date() if last else '—'))
print(' هماهنگی حذف/لغوشده با تاریخ آینده (کاندید قفل آینده): %d'
% Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI, status=Action.Status.CANCELL,
actionDateTime__gt=now).count())
def main():
parser = argparse.ArgumentParser(description='صحت‌سنجی نقص‌های set_visit_hold')
parser.add_argument('--skip-stats', action='store_true')
args = parser.parse_args()
import django
django.setup()
from django.conf import settings
from django.db import connections
source_db = str(settings.DATABASES['default']['NAME'])
if not os.path.isabs(source_db):
source_db = os.path.join(BASE_DIR, source_db)
tmp_dir = tempfile.mkdtemp(prefix='hold_verify_')
settings.DATABASES['default']['NAME'] = _copy_db(source_db, tmp_dir)
connections.close_all()
print('دیتابیسِ کپیِ موقت: %s' % settings.DATABASES['default']['NAME'])
claim_1_silent_failure()
claim_2_no_success_message()
claim_3_stale_column_lock()
claim_4_escape_hatch()
claim_5_edit_to_visit()
if not args.skip_stats:
real_db_stats()
shutil.rmtree(tmp_dir, ignore_errors=True)
if __name__ == '__main__':
main()
+945
View File
@@ -0,0 +1,945 @@
"""شبیه‌سازیِ رفتار «اقدام / هماهنگی / قرار» روی دادهٔ ساختگی.
این فایل، کدِ شبیه‌سازیِ سند ``docs/entezar_action_fields_report.md`` است. هر
کلاس یک سناریوی واقعی را با همان توابع تولیدی (نه بازنویسی) اجرا می‌کند تا
رفتار سیستم قابل بازتولید و قابل حسابرسی بماند:
* ``ActionFormSimulationTests`` — معنی و اثر دو فیلد فرم اقدام:
«تاریخ و ساعتی که کار باید انجام شود» (``dateTimeAnjam``) و
«تا تاریخ پیگیری نشود» (``visitHoldUntil``).
* ``EntezarDecisionSimulationTests`` — اثر آن‌ها بر صفحهٔ انتظار، برچسب
«N قرار» و «قرار دستی».
* ``MissedViewSimulationTests`` — بازتولیدِ باگ گزارش‌شده در
``?view=today&missed=1`` (حضور مشتریِ بازدیدشده). این تست‌ها **رفتار فعلی**
را قفل می‌کنند تا وقتی باگ رفع شد، آگاهانه به‌روزرسانی شوند.
* ``SmsSimulationTests`` — اثر همان فیلدها بر پیامک‌های بازدید
(``visit`` دوره‌ای و ``visit_created`` رویدادی).
اجرا::
.venv/Scripts/python.exe manage.py test tests.test_entezar_action_simulation
.venv/Scripts/python.exe manage.py test tests.test_entezar_action_simulation -v 2
"""
import datetime
from datetime import timedelta
from django.test import TestCase
from django.utils import timezone
from persiantools.jdatetime import JalaliDate
from admin_panel.models import (Action, Customer, PhoneNumber,
SMSProviderConfig, SMSQueue, SMSTemplate, SMSType)
from admin_panel.sms.render import format_jalali
from admin_panel.sms.scanner import scan_visit
from admin_panel.visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL,
SOURCE_SCHEDULED,
appointment_sources_map, as_aware,
end_of_local_day, followups_on_date,
manual_holds_map, next_appointment_map,
overdue_followups)
from .factories import make_customer, make_user
NOW = timezone.now().replace(microsecond=0)
def at(days=0, hours=0):
"""یک لحظهٔ aware نسبت به «همین حالا»."""
return NOW + timedelta(days=days, hours=hours)
def ms(moment):
"""لحظه → timestamp میلی‌ثانیه‌ای، همان چیزی که پیکر تاریخ در فرم می‌فرستد."""
return str(int(moment.timestamp() * 1000))
def jalali(moment):
return JalaliDate(moment).strftime('%Y/%m/%d')
def make_action(customer, action_type, status, *, action_dt=None, finish_dt=None,
**extra):
"""ثبت اقدام با کمترین فیلد ممکن (همان کاری که ویو انجام می‌دهد)."""
return Action.objects.create(
customer=customer, actionType=action_type, status=status,
actionDateTime=action_dt, finishDateTime=finish_dt,
desc='شبیه‌سازی', result='', **extra
)
class SimulationBase(TestCase):
"""مشتریِ «عقب‌افتاده» + کاربر واردشده، مشترک همهٔ سناریوها."""
INSTALL_DAYS = 400
def setUp(self):
self.user = make_user('simExpert', superuser=True)
self.client.force_login(self.user)
self.customer = make_customer('مشتری شبیه‌سازی')
PhoneNumber.objects.create(
customer=self.customer, phone_number='09120000099',
)
Customer.objects.filter(pk=self.customer.pk).update(
installDate=timezone.localdate() - timedelta(days=self.INSTALL_DAYS),
)
self.customer.refresh_from_db()
def post_action(self, **payload):
"""ارسال فرم ایجاد اقدام؛ کلیدهای ناشناخته به payload اضافه می‌شوند."""
data = {'userID': self.user.pk, 'desc': 'شبیه‌سازی'}
data.update(payload)
return self.client.post(
'/panel/customer/%d/action/new' % self.customer.pk, data,
)
def last_action(self):
return Action.objects.filter(customer=self.customer).order_by('-id').first()
def stale_ids(self, view='all'):
response = self.client.get('/panel/entezar/?view=' + view)
self.assertEqual(response.status_code, 200)
return {c.id for c in response.context['customers']}, response
class ActionFormSimulationTests(SimulationBase):
"""دو فیلد تاریخِ فرم: کجا ذخیره می‌شوند و چه محدودیتی دارند."""
def test_action_datetime_is_taken_from_date_time_anjam(self):
"""«تاریخ و ساعتی که کار باید انجام شود» ⇒ ``Action.actionDateTime``."""
when = at(5)
response = self.post_action(actionType='bazdid', dateTimeAnjam=ms(when))
self.assertEqual(response.status_code, 302)
action = self.last_action()
self.assertIsNotNone(action, msg='اقدام باید ثبت شده باشد')
self.assertEqual(action.actionDateTime, when)
self.assertEqual(action.status, Action.Status.WAITE)
self.assertIsNone(action.finishDateTime)
def test_hold_until_is_saved_on_customer_end_of_day(self):
"""«تا تاریخ پیگیری نشود» ⇒ ``Customer.visit_hold_until`` پایانِ همان روز."""
target = at(10)
response = self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(NOW),
visitHoldUntil=ms(target), is_done='on',
)
self.assertEqual(response.status_code, 302)
self.customer.refresh_from_db()
self.assertEqual(self.customer.visit_hold_until,
end_of_local_day(target))
self.assertIsNotNone(self.customer.visit_hold_set_at)
# «تاریخ انجام» دست‌نخورده در اقدام می‌ماند (دو مفهوم، دو فیلد).
self.assertEqual(self.last_action().actionDateTime, NOW)
def test_hold_until_ignored_for_visit_actions(self):
"""برای اقدام «بازدید» فیلد تعویق هیچ اثری ندارد (حتی اگر ارسال شود)."""
self.post_action(
actionType='bazdid', dateTimeAnjam=ms(NOW),
visitHoldUntil=ms(at(10)), is_done='on',
)
self.customer.refresh_from_db()
self.assertIsNone(self.customer.visit_hold_until)
def test_done_coordination_without_hold_defaults_to_14_days(self):
"""تیک «انجام شد» + فیلد خالی ⇒ پیش‌فرض ۱۴ روز."""
self.post_action(actionType='hamahangi', dateTimeAnjam=ms(NOW), is_done='on')
self.customer.refresh_from_db()
self.assertEqual(
self.customer.visit_hold_until,
end_of_local_day(NOW + timedelta(days=14)),
)
self.assertEqual(self.last_action().status, Action.Status.FINISH)
# تاریخ اتمام خالی ⇒ زمان ثبت (نه NULL).
self.assertIsNotNone(self.last_action().finishDateTime)
def test_open_coordination_without_hold_sets_no_hold(self):
"""بدون تیک و بدون تاریخ ⇒ هیچ تعویقی ثبت نمی‌شود؛ مشتری در فهرست می‌ماند."""
self.post_action(actionType='hamahangi', dateTimeAnjam=ms(NOW))
self.customer.refresh_from_db()
self.assertIsNone(self.customer.visit_hold_until)
self.assertEqual(self.last_action().status, Action.Status.WAITE)
ids, _ = self.stale_ids('uncoordinated')
self.assertIn(self.customer.pk, ids)
def test_open_coordination_with_hold_sets_hold_and_stays_open(self):
"""هماهنگیِ «انجام‌نشده» با تاریخ تعویق: قرار ثبت می‌شود ولی وضعیت waite است."""
target = at(7)
self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(NOW), visitHoldUntil=ms(target),
)
self.customer.refresh_from_db()
self.assertEqual(self.customer.visit_hold_until, end_of_local_day(target))
self.assertEqual(self.last_action().status, Action.Status.WAITE)
def test_hold_cannot_be_in_the_past(self):
"""تاریخ تعویقِ گذشته رد می‌شود و هیچ اقدامی ثبت نمی‌شود."""
response = self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(NOW),
visitHoldUntil=ms(at(-1)), is_done='on',
)
self.assertEqual(response.status_code, 302)
self.assertIsNone(self.last_action())
self.customer.refresh_from_db()
self.assertIsNone(self.customer.visit_hold_until)
def test_hold_cannot_exceed_six_months(self):
"""سقف تعویق ۱۸۳ روز (≈۶ ماه) است."""
response = self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(NOW),
visitHoldUntil=ms(at(200)), is_done='on',
)
self.assertEqual(response.status_code, 302)
self.assertIsNone(self.last_action())
self.customer.refresh_from_db()
self.assertIsNone(self.customer.visit_hold_until)
def test_hold_only_moves_forward(self):
"""ثبت هماهنگی دوم با تاریخ کوتاه‌تر، قرار دورتر را عقب نمی‌کشد."""
self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(NOW),
visitHoldUntil=ms(at(10)), is_done='on',
)
self.customer.refresh_from_db()
far = self.customer.visit_hold_until
self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(NOW),
visitHoldUntil=ms(at(3)), is_done='on',
)
self.customer.refresh_from_db()
self.assertEqual(self.customer.visit_hold_until, far)
class EntezarDecisionSimulationTests(SimulationBase):
"""اثر فیلدها روی فهرست انتظار، برچسب وضعیت و ستون «قرار دستی»."""
def test_two_sources_render_two_appointment_badge(self):
"""هماهنگیِ آینده + قرار دستی ⇒ برچسب «۲ قرار» (source_count).
ترکیب واقعیِ دادهٔ تولیدی: ``coordination+manual``.
"""
make_action(self.customer, 'hamahangi', 'finish',
action_dt=at(30), finish_dt=NOW)
self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(at(30)),
visitHoldUntil=ms(at(10)), is_done='on',
)
ids, response = self.stale_ids('all')
self.assertIn(self.customer.pk, ids)
row = response.context['appointment_rows'][self.customer.pk]
self.assertEqual(row['source_count'], 2)
self.assertIsNotNone(row['manual'])
self.assertIsNotNone(row['coordination'])
self.assertIsNone(row['scheduled'])
self.assertIn('2 قرار', response.content.decode())
def test_manual_only_badge_and_column(self):
"""قرار دستی به‌تنهایی: برچسب «قرار دستی» و تاریخ در ستون همان ستون."""
self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(at(-1)),
visitHoldUntil=ms(at(10)), is_done='on',
)
entry = manual_holds_map()
self.assertIn(self.customer.pk, entry)
ids, response = self.stale_ids('all')
self.assertIn(self.customer.pk, ids)
row = response.context['appointment_rows'][self.customer.pk]
self.assertEqual(row['source_count'], 1)
self.assertIsNotNone(row['manual'])
self.assertIsNone(row['coordination'])
self.assertIn('قرار دستی', response.content.decode())
self.assertIn(jalali(at(10)), response.content.decode())
def test_visit_after_coordination_cancels_manual_hold(self):
"""قاعدهٔ «آخرین فعالیت باید هماهنگی باشد»: بازدید بعدی قرار را باطل می‌کند."""
self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(at(-1)),
visitHoldUntil=ms(at(10)), is_done='on',
)
self.assertIn(self.customer.pk, manual_holds_map())
# ثبت بازدید پس از هماهنگی ⇒ ستون هست ولی بی‌اعتبار (قاعدهٔ «آخرین فعالیت»).
# تاریخ بازدید را قدیمی می‌گیریم تا مشتری در نمای «عقب‌افتاده» بماند؛
# ``createdDT`` همیشه «الان» است و همین، قرار را باطل می‌کند.
make_action(self.customer, 'bazdid', 'finish',
action_dt=at(-330), finish_dt=at(-330))
self.assertNotIn(self.customer.pk, manual_holds_map())
self.customer.refresh_from_db()
self.assertIsNotNone(
self.customer.visit_hold_until,
msg='ستون پاک نمی‌شود؛ فقط اعتبارش را از دست می‌دهد',
)
_, response = self.stale_ids('all')
row = response.context['appointment_rows'][self.customer.pk]
self.assertEqual(row['source_count'], 0)
self.assertIn('بدون قرار', response.content.decode())
def test_hold_date_is_the_followup_day(self):
"""قرار دستی، مشتری را فقط در همان روز به نمای «نیازمند پیگیری» می‌آورد."""
self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(at(-1)),
visitHoldUntil=ms(NOW), is_done='on',
)
today = timezone.localdate()
self.assertIn(self.customer.pk, followups_on_date(today))
self.assertNotIn(self.customer.pk, followups_on_date(today + timedelta(days=1)))
ids, _ = self.stale_ids('today')
self.assertIn(self.customer.pk, ids)
def test_scheduled_future_visit_hides_customer(self):
"""«تاریخ انجام»ِ آیندهٔ یک بازدیدِ در انتظار ⇒ خروج از «تعیین‌تکلیف‌نشده»."""
self.post_action(actionType='bazdid', dateTimeAnjam=ms(at(5)))
ids, _ = self.stale_ids('uncoordinated')
self.assertNotIn(self.customer.pk, ids)
scheduled, response = self.stale_ids('scheduled')
self.assertIn(self.customer.pk, scheduled)
self.assertEqual(
response.context['appointment_rows'][self.customer.pk]['source_count'], 1,
)
def test_past_coordination_action_datetime_does_not_hide(self):
"""«تاریخ انجام»ِ گذشته برای هماهنگی، چون «زمان تماس» است، پنهان نمی‌کند."""
make_action(self.customer, 'hamahangi', 'finish',
action_dt=at(-2), finish_dt=at(-2))
self.assertNotIn(self.customer.pk, appointment_sources_map())
ids, _ = self.stale_ids('uncoordinated')
self.assertIn(self.customer.pk, ids)
def test_same_day_sources_are_one_appointment(self):
"""هماهنگی + قرار دستیِ همان روز ⇒ «۲ قرار» نادرست است؛ یک قرار است."""
self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(at(10)),
visitHoldUntil=ms(at(10)), is_done='on',
)
_, response = self.stale_ids('all')
row = response.context['appointment_rows'][self.customer.pk]
self.assertEqual(row['source_count'], 1)
html = response.content.decode()
self.assertNotIn('2 قرار', html)
self.assertIn('قرار دستی', html)
def test_two_different_days_render_two_appointment_badge(self):
"""هماهنگی + قرار دستی در دو روز متفاوت ⇒ «۲ قرار» درست است."""
self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(at(30)),
visitHoldUntil=ms(at(10)), is_done='on',
)
_, response = self.stale_ids('all')
self.assertEqual(
response.context['appointment_rows'][self.customer.pk]['source_count'], 2,
)
self.assertIn('2 قرار', response.content.decode())
class AppointmentLifetimeSimulationTests(SimulationBase):
"""عمر «قرار دستی»: چه چیزی آن را باطل می‌کند و چه چیزی نه.
قاعدهٔ کسب‌وکار: فقط بازدید/نصب قرار را جایگزین می‌کند. یک پرداخت یا یک
تماس ثبت‌شده نباید قرارِ توافق‌شده را بی‌صدا از بین ببرد.
"""
def _hold_customer(self):
self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(at(-1)),
visitHoldUntil=ms(at(12)), is_done='on',
)
self.assertIn(self.customer.pk, manual_holds_map())
return self.customer
def test_payment_does_not_kill_appointment(self):
self._hold_customer()
make_action(self.customer, 'vosol', 'finish', action_dt=NOW, finish_dt=NOW)
self.assertIn(
self.customer.pk, manual_holds_map(),
msg='ثبت سند وصول نباید قرار بازدید را باطل کند',
)
def test_phone_call_does_not_kill_appointment(self):
self._hold_customer()
make_action(self.customer, 'inCall', 'finish', action_dt=NOW, finish_dt=NOW)
make_action(self.customer, 'outCall', 'finish', action_dt=NOW, finish_dt=NOW)
self.assertIn(self.customer.pk, manual_holds_map())
def test_visit_kills_appointment(self):
self._hold_customer()
make_action(self.customer, 'bazdid', 'finish', action_dt=NOW, finish_dt=NOW)
self.assertNotIn(self.customer.pk, manual_holds_map())
def test_install_kills_appointment(self):
self._hold_customer()
make_action(self.customer, 'nasb', 'finish', action_dt=NOW, finish_dt=NOW)
self.assertNotIn(self.customer.pk, manual_holds_map())
class FollowupDaySimulationTests(SimulationBase):
"""فهرست «نیازمند پیگیری امروز»: کارِ باقی‌مانده، نه آرشیو."""
def test_visit_done_today_clears_today_row(self):
make_action(self.customer, 'hamahangi', 'finish',
action_dt=NOW, finish_dt=NOW)
self.assertIn(self.customer.pk, followups_on_date(timezone.localdate()))
make_action(self.customer, 'bazdid', 'finish', action_dt=NOW, finish_dt=NOW)
self.assertNotIn(self.customer.pk, followups_on_date(timezone.localdate()))
ids, _ = self.stale_ids('today')
self.assertNotIn(self.customer.pk, ids)
def test_visit_yesterday_does_not_clear_today_row(self):
"""بازدید دیروز، موعدِ امروز را نمی‌بندد (اگر موعد تازه‌ای گذاشته شده)."""
make_action(self.customer, 'hamahangi', 'finish',
action_dt=NOW, finish_dt=NOW)
make_action(self.customer, 'bazdid', 'finish',
action_dt=at(-1), finish_dt=at(-1))
self.assertIn(self.customer.pk, followups_on_date(timezone.localdate()))
def test_browsing_past_day_keeps_history(self):
"""مرور یک روز گذشته، ردیف را با بازدیدِ بعدتر حذف نمی‌کند."""
yesterday = timezone.localdate() - timedelta(days=1)
make_action(self.customer, 'hamahangi', 'finish',
action_dt=at(-1), finish_dt=at(-1))
make_action(self.customer, 'bazdid', 'finish', action_dt=NOW, finish_dt=NOW)
self.assertIn(self.customer.pk, followups_on_date(yesterday))
class TaskListBoundaryTests(SimulationBase):
"""«لیست وظایف امروز»: مرزهای روز به وقت محلی و aware.
قبلاً مرزها با رشته‌های naive ساخته می‌شدند و به تایم‌زون پیش‌فرض وابسته
بودند؛ وظیفه‌ای در ۰۰:۳۰ امروز یا ۲۳:۳۰ دیروز می‌توانست در/خارج از فهرست
اشتباه بیفتد. این تست مرزها را با وقت محلی قفل می‌کند.
"""
def _task(self, moment, *, done=False):
return Action.objects.create(
customer=self.customer, actionType='bazdid', forUser=self.user,
actionDateTime=moment,
status=Action.Status.FINISH if done else Action.Status.WAITE,
)
def _task_ids(self, url):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
return {row.id for row in response.context['actions']}
def test_local_day_boundaries(self):
today = timezone.localdate()
early = timezone.make_aware(datetime.datetime.combine(
today, datetime.time(0, 30)))
late = timezone.make_aware(datetime.datetime.combine(
today, datetime.time(23, 30)))
yesterday_night = timezone.make_aware(datetime.datetime.combine(
today - timedelta(days=1), datetime.time(23, 30)))
early_task = self._task(early)
late_task = self._task(late)
old_task = self._task(yesterday_night)
ids = self._task_ids('/panel/ActionList/today')
self.assertIn(early_task.pk, ids)
self.assertIn(late_task.pk, ids)
self.assertNotIn(old_task.pk, ids, msg='وظیفهٔ ۲۳:۳۰ دیروز نباید «امروز» باشد')
# فهرست «عقب‌افتاده» همان کاربر: فقط وظیفهٔ دیشب (کلید ``actionPr``).
response = self.client.get('/panel/ActionList/%d/today' % self.user.pk)
backlog = {row.id for row in response.context['actionPr']}
self.assertNotIn(early_task.pk, backlog)
self.assertIn(old_task.pk, backlog)
def test_finished_task_is_not_in_today_list(self):
when = timezone.make_aware(datetime.datetime.combine(
timezone.localdate(), datetime.time(10, 0)))
done = self._task(when, done=True)
self.assertNotIn(done.pk, self._task_ids('/panel/ActionList/today'))
class CacheTableWriteTests(SimulationBase):
"""کارت داشبورد: مقدار کش با شمارندهٔ «تعیین‌تکلیف نشده» هم‌راستا می‌ماند."""
def test_dashboard_counter_matches_uncoordinated(self):
from admin_panel.models import CacheTable
CacheTable.objects.update_or_create(
key='9mahbazdid', defaults={'value': '0'},
)
response = self.client.get('/panel/entezar/?view=all')
self.assertEqual(response.status_code, 200)
expected = str(response.context['counts']['uncoordinated'])
self.assertEqual(CacheTable.objects.get(key='9mahbazdid').value, expected)
class HoldBeforeWorkDateSimulationTests(SimulationBase):
"""سناریوی «تعویق پیش از تاریخ کار» (۱۴۰۵/۰۸/۰۳ در برابر ۱۴۰۵/۰۸/۰۵).
تاریخ‌ها نسبی‌اند تا تست به روز اجرا وابسته نباشد؛ همان فاصله‌ها:
تاریخ کار = امروز+۵ · تعویق = امروز+۳ (پایان روز).
نکتهٔ مهم: هیچ اعتبارسنجی‌ای تعویقِ **زودتر از تاریخ کار** را رد نمی‌کند؛
هر دو فقط باید در آینده باشند. این تست رفتار فعلی را قفل می‌کند.
"""
def _create(self, *, done):
payload = {
'actionType': 'hamahangi',
'dateTimeAnjam': ms(at(5)),
'visitHoldUntil': ms(at(3)),
}
if done:
payload['is_done'] = 'on'
self.post_action(**payload)
self.customer.refresh_from_db()
return self.last_action()
def test_earlier_hold_is_accepted(self):
"""تعویقِ قبل از تاریخ کار پذیرفته می‌شود و به پایان همان روز می‌رسد."""
action = self._create(done=True)
self.assertIsNotNone(action)
self.assertEqual(self.customer.visit_hold_until, end_of_local_day(at(3)))
self.assertEqual(action.actionDateTime, at(5))
def test_badge_is_two_appointments(self):
"""دو تاریخ متفاوت ⇒ «۲ قرار»، هرچند در عمل یک توافق است."""
self._create(done=True)
_, response = self.stale_ids('all')
row = response.context['appointment_rows'][self.customer.pk]
self.assertEqual(row['source_count'], 2)
self.assertIn('2 قرار', response.content.decode())
def test_hold_day_creates_an_extra_followup_row(self):
"""روزِ تعویق، مشتری در فهرست «نیازمند پیگیری» می‌آید، در حالی که
قرار اصلی هنوز دو روز بعد است (ردیفِ کاریِ تکراری)."""
self._create(done=True)
today = timezone.localdate()
self.assertIn(self.customer.pk, followups_on_date(today + timedelta(days=3)))
self.assertIn(self.customer.pk, followups_on_date(today + timedelta(days=5)))
# در روزِ میانی (بین تعویق و تاریخ کار) قرارِ هماهنگی پابرجاست.
self.assertNotIn(self.customer.pk, followups_on_date(today + timedelta(days=4)))
def test_scanner_stays_quiet_between_hold_and_work_date(self):
"""در فاصلهٔ پایان تعویق تا تاریخ کار، «قرار بعدی» هماهنگی است ⇒
یادآوری دوره‌ای ارسال نمی‌شود (صفحه و پیامک واگرا نمی‌شوند)."""
self._create(done=True)
for offset in (0, 2, 3, 4, 5):
moment = at(offset)
self.assertIn(
self.customer.pk, next_appointment_map(now=moment),
msg='در روز +%d باید قرار فعال باشد' % offset,
)
def test_customer_falls_into_missed_right_after_work_date(self):
"""چون تعویق قبل از تاریخ کار تمام شده، فردای تاریخ کار مشتری
«بدون قرار» و «عقب‌افتاده» است (مسیر تصمیم محصولی)."""
self._create(done=True)
after = at(6)
self.assertNotIn(self.customer.pk, appointment_sources_map(now=after))
self.assertIn(
self.customer.pk,
overdue_followups(now=after, target_date=after.date()),
)
def test_done_and_open_have_same_hold_effect(self):
"""تیک «انجام شد» روی قرار/پنهان‌سازی اثری ندارد؛ فقط وضعیت عوض می‌شود."""
action_open = self._create(done=False)
hold_open = self.customer.visit_hold_until
self.assertEqual(action_open.status, Action.Status.WAITE)
Action.objects.filter(customer=self.customer).delete()
Customer.objects.filter(pk=self.customer.pk).update(visit_hold_until=None)
self.customer.refresh_from_db()
action_done = self._create(done=True)
self.assertEqual(action_done.status, Action.Status.FINISH)
self.assertEqual(self.customer.visit_hold_until, hold_open)
def test_done_leaves_task_list_and_enters_survey(self):
"""تیک‌دار: از «لیست وظایف باز» خارج و به صف «نظرسنجی» اضافه می‌شود."""
action = self._create(done=True)
self.assertEqual(
Action.objects.filter(pk=action.pk, status=Action.Status.WAITE).count(), 0,
)
survey = Action.objects.exclude(actionType='vosol').filter(
status=Action.Status.FINISH, nazarDT__isnull=True,
)
self.assertIn(action.pk, {row.pk for row in survey})
def test_open_stays_in_task_list_without_survey(self):
"""بدون تیک: وظیفه باز می‌ماند (در لیست وظایف/شمارندهٔ داشبورد) و
هیچ نظرسنجی‌ای ساخته نمی‌شود."""
action = self._create(done=False)
self.assertEqual(
Action.objects.filter(pk=action.pk, status=Action.Status.WAITE).count(), 1,
)
survey = Action.objects.exclude(actionType='vosol').filter(
status=Action.Status.FINISH, nazarDT__isnull=True,
)
self.assertNotIn(action.pk, {row.pk for row in survey})
def test_done_without_finish_date_records_now_not_work_date(self):
"""تاریخ اتمام خالی ⇒ زمان ثبت؛ نه تاریخِ کار (تاریخ کار ۲ روز بعدتر است)."""
action = self._create(done=True)
self.assertLess(action.finishDateTime, action.actionDateTime)
class HoldBackwardConfirmTests(SimulationBase):
"""«عقب‌بردن» تاریخ قرار: پیام شفاف + تأیید صریح کاربر.
قاعده: سیستم بی‌صدا تاریخ را عقب نمی‌برد؛ کاربر باید دلیل را ببیند و اگر
خواست، خودش با تیک ``force_hold`` اجازه بدهد.
"""
def _messages(self, response):
return [str(m) for m in response.context['messages']]
def _make_hold(self, days=10):
"""یک هماهنگی با قرار ۱۰ روز بعد؛ ستون مشتری همان می‌شود."""
self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(at(-1)),
visitHoldUntil=ms(at(days)), is_done='on',
)
self.customer.refresh_from_db()
return self.customer.visit_hold_until
def _edit_hold(self, action, *, days, force=False):
payload = {
'actionType': 'hamahangi',
'dateTimeAnjam': ms(at(-1)),
'visitHoldUntil': ms(at(days)),
'is_done': 'on',
'userID': self.user.pk,
'desc': 'ویرایش قرار',
}
if force:
payload['force_hold'] = '1'
return self.client.post(
'/panel/customer/%d/action/%d/edit' % (self.customer.pk, action.pk),
payload,
HTTP_REFERER='/panel/customer/%d/action' % self.customer.pk,
follow=True,
)
def test_earlier_date_is_not_saved_and_user_is_told(self):
hold = self._make_hold(days=10)
action = self.last_action()
response = self._edit_hold(action, days=3)
self.customer.refresh_from_db()
self.assertEqual(self.customer.visit_hold_until, hold,
msg='قرار نباید عقب برود')
text = ' | '.join(self._messages(response))
self.assertIn('ذخیره نشد', text)
self.assertIn(jalali(at(10)), text) # قرار فعلی
self.assertIn('تاریخ قرار را عقب ببر', text)
# پیام باید در خودِ صفحه هم رندر شود (کاربر آن را می‌بیند)، نه فقط در session.
self.assertIn('ذخیره نشد', response.content.decode())
def test_user_confirmation_writes_the_earlier_date(self):
self._make_hold(days=10)
action = self.last_action()
self._edit_hold(action, days=3, force=True)
self.customer.refresh_from_db()
self.assertEqual(
self.customer.visit_hold_until, end_of_local_day(at(3)),
msg='پس از تأیید صریح، تاریخ عقب‌تر باید ذخیره شود',
)
def test_forward_date_needs_no_confirmation(self):
self._make_hold(days=3)
action = self.last_action()
response = self._edit_hold(action, days=20)
self.customer.refresh_from_db()
self.assertEqual(self.customer.visit_hold_until, end_of_local_day(at(20)))
self.assertNotIn('ذخیره نشد', ' | '.join(self._messages(response)))
def test_same_day_is_silent(self):
hold = self._make_hold(days=10)
action = self.last_action()
response = self._edit_hold(action, days=10)
self.customer.refresh_from_db()
self.assertEqual(self.customer.visit_hold_until, hold)
self.assertNotIn('ذخیره نشد', ' | '.join(self._messages(response)))
def test_create_does_not_move_the_hold_backward(self):
"""در «ایجاد» عقب‌بردن قرار مجاز نیست — حتی با force_hold.
وظیفهٔ تازه ثبت می‌شود (کار روزانه‌اش را انجام می‌دهد) ولی قرارِ
ثبت‌شدهٔ قبلی — شاید کارِ کارشناس دیگری — کوتاه نمی‌شود.
"""
hold = self._make_hold(days=10)
before = Action.objects.filter(customer=self.customer).count()
response = self.client.post(
'/panel/customer/%d/action/new' % self.customer.pk,
{'actionType': 'hamahangi', 'dateTimeAnjam': ms(at(-1)),
'visitHoldUntil': ms(at(4)), 'is_done': 'on',
'userID': self.user.pk, 'desc': 'دوم', 'force_hold': '1'},
follow=True,
)
self.customer.refresh_from_db()
self.assertEqual(self.customer.visit_hold_until, hold,
msg='در ایجاد، حتی با force_hold نباید عقب برود')
self.assertEqual(
Action.objects.filter(customer=self.customer).count(), before + 1,
msg='خودِ وظیفه باید ثبت شود',
)
text = ' | '.join(self._messages(response))
self.assertIn('ذخیره نشد', text)
self.assertIn('ویرایش کنید', text)
def test_apply_visit_hold_reports_reason(self):
"""قرارداد تابع: دلیل شکست برگردانده می‌شود، نه True بی‌قید."""
from admin_panel.views.actions import _apply_visit_hold
self._make_hold(days=10)
result = _apply_visit_hold(
self.customer,
{'actionType': 'hamahangi', 'holdRaw': at(2),
'status': Action.Status.FINISH},
status=Action.Status.FINISH, is_new=False,
)
self.assertFalse(result['ok'])
self.assertEqual(result['reason'], 'forward_only')
self.assertIsNotNone(result['current'])
self.assertIsNotNone(result['target'])
self.assertTrue(result['force_allowed'], msg='در ویرایش، عقب‌بردن مجاز است')
def test_apply_visit_hold_ignores_force_on_create(self):
"""در ایجاد، force_hold نادیده گرفته می‌شود (قرار کوتاه نمی‌شود)."""
from admin_panel.views.actions import _apply_visit_hold
hold = self._make_hold(days=10)
result = _apply_visit_hold(
self.customer,
{'actionType': 'hamahangi', 'holdRaw': at(2),
'status': Action.Status.FINISH},
status=Action.Status.FINISH, is_new=True, force_hold=True,
)
self.customer.refresh_from_db()
self.assertFalse(result['ok'])
self.assertFalse(result['force_allowed'])
self.assertEqual(self.customer.visit_hold_until, hold)
def test_modal_exposes_confirmation_controls(self):
"""قالب: اعلانِ ساده + تیک تأیید (فقط ویرایش) + راهنمای مسیر ایجاد."""
self._make_hold(days=10)
html = self.client.get(
'/panel/customer/%d/action' % self.customer.pk).content.decode()
self.assertIn('data-customer-hold=', html)
self.assertIn('name="force_hold"', html)
self.assertIn('id="holdBackwardNotice"', html)
self.assertIn('id="forceHoldCheck"', html) # چک‌باکس (نه دکمه)
self.assertIn('type="checkbox"', html)
self.assertIn('id="hbCurrent"', html) # «قرار فعلی» در اعلان
self.assertIn('id="hbForceRow"', html) # تیک فقط در ویرایش
self.assertIn('id="hbCreateHint"', html) # مسیر ایجاد: ویرایش وظیفه
self.assertIn('تاریخ قرار را عقب ببر', html) # متن کوتاه و روشن
self.assertIn('syncHoldBackward', html)
# کلاس مستقل از پنل «انجام شد» تا CSS آن دکمه‌ها را پنهان نکند.
self.assertIn('ax-holdback', html)
self.assertNotIn('holdBackwardYes', html)
class MissedViewSimulationTests(SimulationBase):
"""رفتار فهرست ``?view=today&missed=1`` پس از رفع باگ.
قاعدهٔ کسب‌وکار: این فهرست فقط «پیگیری‌های واقعاً باقی‌مانده» را نشان می‌دهد.
هر مشتری که (الف) بازدیدش از موعد به بعد انجام شده، یا (ب) قرار دستیِ معتبر
دارد، یا (ج) بازدیدِ در انتظار از آن روز دارد، از فهرست خارج می‌شود.
"""
URL = '/panel/entezar/?view=today&missed=1'
def missed(self):
response = self.client.get(self.URL)
self.assertEqual(response.status_code, 200)
return {c.id for c in response.context['customers']}, response
def test_finished_visit_after_coordination_is_not_listed(self):
"""هماهنگی دیروز + بازدید انجام‌شدهٔ امروز ⇒ نباید در missed بیاید."""
make_action(self.customer, 'hamahangi', 'finish',
action_dt=at(-1, -2), finish_dt=at(-1))
make_action(self.customer, 'bazdid', 'finish',
action_dt=NOW, finish_dt=NOW)
ids, response = self.missed()
self.assertNotIn(self.customer.pk, ids)
self.assertNotIn(self.customer.pk, overdue_followups())
self.assertEqual(response.context['counts']['followup_overdue'], 0)
def test_visit_before_due_does_not_clear(self):
"""بازدیدِ قدیمی‌تر از موعد، پیگیریِ جدید را نمی‌بندد."""
make_action(self.customer, 'bazdid', 'finish',
action_dt=at(-330), finish_dt=at(-330))
make_action(self.customer, 'hamahangi', 'finish',
action_dt=at(-2), finish_dt=at(-2))
ids, _ = self.missed()
self.assertIn(self.customer.pk, ids)
def test_future_manual_hold_is_not_listed(self):
"""«تا تاریخ پیگیری نشود» معتبر ⇒ نه در missed، نه در شمارندهٔ قرمز."""
self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(at(-3)),
visitHoldUntil=ms(at(10)), is_done='on',
)
self.assertIn(self.customer.pk, manual_holds_map())
ids, response = self.missed()
self.assertNotIn(self.customer.pk, ids)
self.assertEqual(response.context['counts']['followup_overdue'], 0)
def test_dead_manual_hold_is_still_listed(self):
"""قرارِ بی‌اعتبار (بازدید بعد از آن ثبت شده) مانع نمایش نیست."""
self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(at(-3)),
visitHoldUntil=ms(at(10)), is_done='on',
)
# بازدیدِ قدیمی‌تر از موعد هماهنگی: قرار را باطل می‌کند ولی پیگیری را نمی‌بندد.
make_action(self.customer, 'bazdid', 'finish',
action_dt=at(-400), finish_dt=at(-400))
self.assertNotIn(self.customer.pk, manual_holds_map())
ids, _ = self.missed()
self.assertIn(self.customer.pk, ids)
def test_pending_visit_removes_from_missed(self):
"""قاعدهٔ موجود: بازدیدِ «در انتظار» با تاریخ امروز، مشتری را خارج می‌کند."""
make_action(self.customer, 'hamahangi', 'finish',
action_dt=at(-3), finish_dt=at(-3))
make_action(self.customer, 'bazdid', 'waite', action_dt=at(0, 1))
ids, _ = self.missed()
self.assertNotIn(self.customer.pk, ids)
def test_old_pending_visit_does_not_remove(self):
"""بازدیدِ در انتظارِ قدیمی‌تر از موعد، قرار فعال نیست ⇒ در missed می‌ماند."""
make_action(self.customer, 'hamahangi', 'finish',
action_dt=at(-30), finish_dt=at(-30))
make_action(self.customer, 'bazdid', 'waite', action_dt=at(-40))
ids, _ = self.missed()
self.assertIn(self.customer.pk, ids)
def test_fresh_coordination_removes_from_missed(self):
"""موعدِ تازه (هماهنگی امروز/آینده) ⇒ دیگر «عقب‌افتاده» نیست."""
make_action(self.customer, 'hamahangi', 'finish',
action_dt=at(-30), finish_dt=at(-30))
make_action(self.customer, 'hamahangi', 'waite', action_dt=at(5))
ids, _ = self.missed()
self.assertNotIn(self.customer.pk, ids)
def test_overdue_counter_equals_missed_rows(self):
"""عدد هشدار سرخ = تعداد ردیف‌های همان فهرست (سازگاری شمارنده و لیست)."""
make_action(self.customer, 'hamahangi', 'finish',
action_dt=at(-2), finish_dt=at(-2))
ids, response = self.missed()
self.assertEqual(response.context['counts']['followup_overdue'], len(ids))
self.assertIn(self.customer.pk, ids)
def test_hold_only_moves_forward(self):
"""ثبت هماهنگی دوم با تاریخ کوتاه‌تر، قرار دورتر را عقب نمی‌کشد."""
self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(NOW),
visitHoldUntil=ms(at(10)), is_done='on',
)
self.customer.refresh_from_db()
far = self.customer.visit_hold_until
self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(NOW),
visitHoldUntil=ms(at(3)), is_done='on',
)
self.customer.refresh_from_db()
class SmsSimulationTests(SimulationBase):
"""پیامک‌های متأثر از دو فیلد: یادآوری دوره‌ای ``visit`` و ``visit_created``."""
def setUp(self):
super().setUp()
config = SMSProviderConfig.load()
config.scan_since = timezone.now() - timedelta(days=500)
config.allow_backlog = True
config.backlog_max = 100
config.max_per_run = 200
config.is_enabled = True
config.save()
SMSTemplate.objects.filter(sms_type=SMSType.VISIT).update(is_enabled=True)
SMSTemplate.objects.filter(
sms_type=SMSType.VISIT_CREATED,
).update(is_enabled=True)
def scan(self, moment=None):
moment = moment or NOW
return scan_visit(moment.date(), now=moment)
def created_sms(self):
return SMSQueue.objects.filter(
customer=self.customer, sms_type=SMSType.VISIT_CREATED,
)
def test_baseline_visit_reminder_is_queued(self):
"""مشتریِ عقب‌افتاده بدون قرار ⇒ یادآوری دوره‌ای در صف قرار می‌گیرد."""
self.assertEqual(self.scan(), 1)
self.assertEqual(
SMSQueue.objects.filter(customer=self.customer,
sms_type=SMSType.VISIT).count(), 1,
)
def test_hold_until_suppresses_visit_reminder(self):
"""«تا تاریخ پیگیری نشود» (با هماهنگی فعال) ⇒ یادآوری دوره‌ای ارسال نمی‌شود."""
self.post_action(
actionType='hamahangi', dateTimeAnjam=ms(NOW),
visitHoldUntil=ms(at(10)), is_done='on',
)
self.assertEqual(self.scan(), 0)
# پس از پایان قرار، در اجرای بعدی اسکنر دوباره واجد شرط می‌شود.
later = NOW + timedelta(days=12)
self.assertEqual(self.scan(later), 1)
def test_hold_column_without_coordination_is_inert(self):
"""ستون تعویق به‌تنهایی (بدون اقدام هماهنگی) هیچ اثری ندارد ⇒ پیامک می‌رود."""
self.customer.visit_hold_until = end_of_local_day(at(10))
self.customer.save(update_fields=['visit_hold_until'])
self.assertEqual(self.scan(), 1)
def test_scheduled_future_visit_suppresses_reminder(self):
"""«تاریخ انجام»ِ آینده روی بازدید در انتظار ⇒ یادآوری دوره‌ای قطع می‌شود."""
self.post_action(actionType='bazdid', dateTimeAnjam=ms(at(5)))
self.assertEqual(self.scan(), 0)
def test_future_coordination_suppresses_reminder(self):
"""هماهنگی با «تاریخ انجام» آینده ⇒ یادآوری دوره‌ای قطع می‌شود."""
self.post_action(actionType='hamahangi', dateTimeAnjam=ms(at(5)))
self.assertEqual(self.scan(), 0)
def test_past_coordination_date_does_not_suppress_reminder(self):
"""«تاریخ انجام»ِ گذشته (زمان تماس) ⇒ قرار فعال نیست؛ پیامک می‌رود."""
make_action(self.customer, 'hamahangi', 'finish',
action_dt=at(-1), finish_dt=at(-1))
self.assertEqual(self.scan(), 1)
def test_visit_action_sends_visit_created_with_action_datetime(self):
"""اقدام «بازدید» ⇒ پیامک ``visit_created`` با تاریخِ فیلد «تاریخ انجام»."""
when = at(1)
self.post_action(actionType='bazdid', dateTimeAnjam=ms(when))
self.assertEqual(self.created_sms().count(), 1)
message = self.created_sms().first().message
self.assertIn(format_jalali(when), message)
def test_coordination_does_not_send_visit_created(self):
"""«هماهنگی» هیچ پیامک ``visit_created`` نمی‌فرستد (فقط بازدید)."""
self.post_action(actionType='hamahangi', dateTimeAnjam=ms(at(1)),
visitHoldUntil=ms(at(10)), is_done='on')
self.assertEqual(self.created_sms().count(), 0)
def test_hold_until_does_not_block_visit_created(self):
"""قرار دستی، پیامک رویدادی «ثبت بازدید» را مسدود نمی‌کند."""
self.post_action(actionType='hamahangi', dateTimeAnjam=ms(NOW),
visitHoldUntil=ms(at(10)), is_done='on')
self.post_action(actionType='bazdid', dateTimeAnjam=ms(at(1)))
self.assertEqual(self.created_sms().count(), 1)
def test_creating_visit_clears_hold_column(self):
"""ثبت اقدام غیرهماهنگی (بازدید) در حالت ایجاد، ستون قرار را پاک می‌کند."""
self.post_action(actionType='hamahangi', dateTimeAnjam=ms(NOW),
visitHoldUntil=ms(at(10)), is_done='on')
self.customer.refresh_from_db()
self.assertIsNotNone(self.customer.visit_hold_until)
self.post_action(actionType='bazdid', dateTimeAnjam=ms(at(1)))
self.customer.refresh_from_db()
self.assertIsNone(self.customer.visit_hold_until)
+10 -4
View File
@@ -1417,8 +1417,10 @@ class CoordinationDoneConfirmTests(TestCase):
self.assertIn('holdAutoChecked = false', block)
def test_confirmation_is_gated_before_submit(self):
block = self.html.split('$("#ActionBox").on(\'submit\'', 1)[1]
block = block.split('});', 1)[0]
# هندلر «انجام شد» دومی است؛ هندلر اول مربوط به «عقب‌بردن تاریخ قرار»
# است. پس بلوک را از هندلری می‌گیریم که به showHoldConfirm می‌رسد.
block = self.html.split('showHoldConfirm();', 1)[0].rsplit(
'$("#ActionBox").on(\'submit\'', 1)[1]
# سه شرط خروج زودهنگام: ویرایش/تأییدشده، نوع غیر هماهنگی، تیک نخورده.
self.assertIn('isEditMode || holdConfirmed', block)
self.assertIn("!== 'hamahangi'", block)
@@ -2010,8 +2012,12 @@ class TodayFollowupTests(TestCase):
# ---------- ستون «تأخیر» فقط در نمای پیگیری مخفی ----------
def test_overdue_column_hidden_only_in_followup_view(self):
customer = self._customer('ستون تأخیر')
self._coordination(customer, 0)
# مشتری بدون هیچ قرار: در «تعیین تکلیف نشده» ردیف دارد، پس سلول ستون
# «تأخیر» رندر می‌شود. (نسخهٔ قبلی از یک هماهنگیِ «امروز ساعت ۱۲»
# استفاده می‌کرد؛ اگر تست پیش از ظهر اجرا می‌شد آن هماهنگی «آینده»
# شمرده می‌شد، مشتری از فهرست خارج می‌شد، جدول خالی می‌ماند و تست
# به ساعتِ اجرا وابسته بود.)
self._customer('ستون تأخیر')
followup_html = self.client.get(self.URL + '?view=today').content.decode()
self.assertNotIn('data-label="تأخیر"', followup_html)