improve bazdid 6
This commit is contained in:
1 parent
99c25a8216
commit
fa73a734a0
21 files changed
+32760
-7985
No files matched your search
+8
-6
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) |
|
||||
@@ -43,13 +43,20 @@
|
||||
>
|
||||
> ۱. تاریخش از حالا جلوتر باشد،
|
||||
> ۲. برای همان مشتری یک اقدام **هماهنگیِ فعال** (`waite` یا `finish`) وجود داشته باشد،
|
||||
> ۳. **آخرین فعالیتِ مشتری همان هماهنگی باشد** — اگر بعد از آن، اقدام دیگری
|
||||
> (بهویژه «بازدید») ثبت شده باشد، قرار منتفی است.
|
||||
> ۳. **آخرین اقدامِ جایگزینکنندهٔ قرار، همان هماهنگی باشد** — اگر بعد از آن
|
||||
> «بازدید» (`bazdid`) یا «نصب» (`nasb`) ثبت شده باشد، قرار منتفی است.
|
||||
> اقدامهای دیگر (وصول، تماس ورودی/خروجی، …) قرار را باطل **نمیکنند**.
|
||||
|
||||
شرط ۳ برای این است که «ثبت بازدید پس از هماهنگی» قرار را خودکار باطل کند.
|
||||
بدون آن، قرار قدیمی تا تاریخش زنده میماند و مشتری در فهرست «با قرار» میماند،
|
||||
هرچند بازدید جای هماهنگی را گرفته است.
|
||||
|
||||
> **اصلاح ۲۰۲۶-۱۰-۲۲:** قبلاً «هر اقدام غیرهماهنگی» قرار را باطل میکرد؛ نتیجه
|
||||
> این بود که یک سند وصول یا یک تماس ثبتشده، قرارِ توافقشده را بیصدا از بین
|
||||
> میبرد و مشتری دوباره در فهرست تماس ظاهر میشد. حالا فقط `bazdid`/`nasb`
|
||||
> (یعنی اقدامهایی که واقعاً جای قرار مینشینند) قرار را باطل میکنند
|
||||
> (`visit_policy._SUPERSEDING_TYPES`).
|
||||
|
||||
نتیجهٔ این قاعده:
|
||||
|
||||
| رویداد | اثر روی قرار |
|
||||
|
||||
@@ -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 @@
|
||||
{"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
@@ -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._
|
||||
|
||||
@@ -63,12 +63,12 @@
|
||||
</div>
|
||||
<div id="legend"></div>
|
||||
</div>
|
||||
<div id="stats">1991 nodes · 3632 edges · 207 communities</div>
|
||||
<div id="stats">2509 nodes · 4876 edges · 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'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'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) {
|
||||
|
||||
+28130
-7627
File diff suppressed because it is too large.
Load diff
+113
-71
@@ -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
@@ -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',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user