improve bazdid 6

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

No files matched your search

@@ -318,6 +318,29 @@
#ActionBox .ax-warn.is-confirming .ax-warn-actions { display: flex; }
#ActionBox .ax-warn:focus { outline: 2px solid #f59e0b; outline-offset: 2px; }
/* اعلان «تاریخ قرار عقب‌تر» — کلاس مستقل تا به CSS پنل «انجام شد» وابسته نباشد */
#ActionBox .ax-holdback {
margin-top: .5rem; padding: .6rem .7rem;
border: 1px solid #fcd34d; border-radius: .5rem; background: #fffbeb;
color: #92400e; font-size: .78rem; line-height: 1.75;
}
#ActionBox .ax-holdback.is-blocking {
border-color: #f59e0b; box-shadow: 0 0 0 3px #fde68a66; background: #fef3c7;
}
#ActionBox .ax-holdback .ax-hb-title { font-weight: 800; color: #78350f; margin: 0 0 .15rem; }
#ActionBox .ax-holdback .ax-hb-current { margin: 0 0 .4rem; }
#ActionBox .ax-holdback .ax-hb-current b { font-weight: 800; }
#ActionBox .ax-holdback .ax-hb-alt { margin: 0; }
#ActionBox .ax-holdback .ax-hb-check {
display: flex; align-items: flex-start; gap: .5rem; margin: 0;
padding: .5rem .6rem; border-radius: .45rem; background: #fff;
border: 1px solid #fbbf24; cursor: pointer; font-weight: 700; color: #7c2d12;
}
#ActionBox .ax-holdback .ax-hb-check input {
width: 1.05rem; height: 1.05rem; margin-top: .15rem; flex: 0 0 auto;
}
#ActionBox .ax-holdback:focus { outline: 2px solid #f59e0b; outline-offset: 2px; }
#ActionBox .ax-help-toggle {
display: flex; align-items: center; gap: .45rem; width: 100%;
padding: .55rem .7rem; border: 1px solid #bae6fd; border-radius: .5rem;
@@ -352,12 +375,15 @@
</style>
{% if customer %}
<form id="ActionBox" class="modal" method="post" action="{% url 'admin_panel:customerNewAction' customer.id %}">
<form id="ActionBox" class="modal" method="post" action="{% url 'admin_panel:customerNewAction' customer.id %}"
data-customer-hold="{% if customer.visit_hold_until %}{{ customer.visit_hold_until|date:'c' }}{% endif %}">
{% else %}
<form id="ActionBox" class="modal" method="post" action="">
{% endif %}
{% csrf_token %}
{# تأیید صریح کاربر برای عقب‌بردن تاریخ قرار؛ فقط با زدن دکمهٔ تأیید پر می‌شود. #}
<input type="hidden" name="force_hold" id="forceHold" value="">
<div class="modal-background closeActionBox"></div>
<div class="modal-card ax-card">
<header class="modal-card-head ax-head">
@@ -427,6 +453,20 @@
</div>
</div>
{# اعلان «تاریخ عقب‌تر» — عرض کامل، بیرون از شبکهٔ دوستونه #}
<div class="ax-holdback is-hidden" id="holdBackwardNotice" tabindex="-1"
role="alert" aria-live="polite">
<p class="ax-hb-title">این تاریخ از قرار فعلی عقب‌تر است</p>
<p class="ax-hb-current">قرار فعلی: <b id="hbCurrent">—</b></p>
<label class="ax-hb-check is-hidden" id="hbForceRow" for="forceHoldCheck">
<input type="checkbox" id="forceHoldCheck">
<span>تاریخ قرار را عقب ببر</span>
</label>
<p class="ax-hb-alt is-hidden" id="hbCreateHint">
این تاریخ ذخیره نمی‌شود. برای تغییر قرار، وظیفهٔ هماهنگی فعلی را ویرایش کنید.
</p>
</div>
{# راهنمای بازشونده — فقط برای هماهنگی #}
<div class="ax-help-wrap showWhenCoordination is-hidden" style="margin-top: .6rem;">
<button type="button" class="ax-help-toggle" id="holdHelpToggle"
@@ -451,11 +491,13 @@
<h4>نکات مهم</h4>
<ul>
<li><b>خالی + تیک «انجام شد»</b> ← سیستم خودش ۱۴ روز می‌گذارد.</li>
<li><b>خالی + تیک</b> ← سیستم خودش ۱۴ روز می‌گذارد.</li>
<li><b>خالی + بدون تیک</b> ← هیچ تعویقی ثبت نمی‌شود؛ مشتری در فهرست می‌ماند.</li>
<li><b>فقط تمدید می‌کند، کوتاه نمی‌کند:</b> اگر قرار ۲۲ آذر باشد و همکار دیگری ۳۰ آذر بگذارد،
قرار به ۳۰ آذر می‌رود؛ اما ۱۵ آذر نادیده گرفته می‌شود. برای کوتاه‌کردن،
هماهنگی را حذف و دوباره ثبت کنید.</li>
قرار به ۳۰ آذر می‌رود؛ تاریخ‌های عقب‌تر نادیده گرفته می‌شوند تا قرارِ دورتر از بین نرود.
اگر خودتان می‌خواهید قرار را عقب ببرید، وظیفه را <b>ویرایش</b> کنید و
گزینهٔ <b>«تاریخ قرار را عقب ببر»</b> را تیک بزنید
(در «ایجاد وظیفهٔ تازه» این گزینه وجود ندارد).</li>
<li><b>حذف یا لغو هماهنگی</b> ← قرار بی‌اثر می‌شود و مشتری بلافاصله برمی‌گردد.</li>
<li><b>سقف ۶ ماه</b> است؛ بیشتر از آن با پیام خطا رد می‌شود.</li>
<li>این فیلد <b>تاریخ بازدید پرسنل نیست</b> و <b>بازدید حساب نمی‌شود</b>؛
@@ -596,6 +638,7 @@
dateTimePickerAnjam.setDate();
dateTimePickerFinish.setDate();
resetHoldPicker();
syncHoldBackward();
$(".showWhenCoordination").addClass("is-hidden");
closeHoldHelp();
$("#sabtBtn05").val("ثبت");
@@ -649,8 +692,109 @@
$("#checkIsDone").trigger('focus');
});
// ══════════ عقب‌بردن تاریخ قرار ══════════
// قاعدهٔ سیستم: تاریخ «تا تاریخ پیگیری نشود» فقط رو به جلو می‌رود تا قرارِ
// دورتری که ثبت شده از بین نرود. در «ویرایش» اگر کاربر خودش بخواهد عقب ببرد،
// قرار فعلی را می‌بیند و با تیک زدن اجازهٔ صریح می‌دهد. در «ایجادِ» وظیفهٔ تازه
// این اجازه داده نمی‌شود؛ چون کاربر قصد اصلاح قرارِ موجود را ندارد و ممکن است
// ناخواسته قولِ ثبت‌شدهٔ قبلی را کوتاه کند.
function holdCurrentMs() {
var iso = ($("#ActionBox").attr('data-customer-hold') || '').trim();
if (!iso) { return null; }
var ms = new Date(iso).getTime();
return isNaN(ms) ? null : ms;
}
// مقدار پیکر در فیلد مخفی به‌صورت timestamp میلی‌ثانیه‌ای است؛ اگر پیکر
// تاریخ شمسی متنی گذاشت، همان را هم می‌پذیریم.
function holdTargetMs() {
var raw = ($("#visitHoldUntil").val() || '').trim();
if (!raw) { return null; }
if (/^\d+$/.test(raw)) {
var n = parseInt(raw, 10);
return isNaN(n) ? null : n;
}
var parsed = Date.parse(raw.replace(/\//g, '-'));
return isNaN(parsed) ? null : parsed;
}
function sameLocalDay(a, b) {
var da = new Date(a), db = new Date(b);
return da.getFullYear() === db.getFullYear()
&& da.getMonth() === db.getMonth()
&& da.getDate() === db.getDate();
}
function jalaliDay(ms) {
if (ms === null) { return '—'; }
try {
if (typeof persianDate !== 'undefined') {
return new persianDate(ms).format('YYYY/MM/DD');
}
} catch (err) { /* کتابخانه در دسترس نیست ⇒ تاریخ میلادی */ }
var d = new Date(ms);
return d.getFullYear() + '/' + (d.getMonth() + 1) + '/' + d.getDate();
}
// آیا تاریخ انتخابی از قرار فعلی عقب‌تر است؟ (مستقل از تیک کاربر)
function holdIsBackward() {
if ($("#actionType").val() !== 'hamahangi') { return false; }
var current = holdCurrentMs(), target = holdTargetMs();
if (current === null || target === null) { return false; }
if (sameLocalDay(current, target)) { return false; }
return target < current;
}
// به‌روزرسانی اعلان (و اجازهٔ عقب‌بردن فقط در ویرایش) — با هر تغییر تاریخ/نوع.
function syncHoldBackward() {
var notice = $("#holdBackwardNotice");
var needed = holdIsBackward();
var canForce = needed && isEditMode;
// تیک فقط وقتی معنا دارد که عقب‌بردن مجاز باشد؛ در غیر این صورت پاک می‌شود.
$("#hbForceRow").toggleClass("is-hidden", !canForce);
$("#hbCreateHint").toggleClass("is-hidden", !needed || canForce);
if (!canForce) {
$("#forceHoldCheck").prop('checked', false);
$("#forceHold").val('');
}
if (!needed) {
notice.addClass("is-hidden").removeClass("is-blocking");
return;
}
$("#hbCurrent").text(jalaliDay(holdCurrentMs()));
notice.removeClass("is-hidden");
}
// تیک «عقب ببر» ⇒ فیلد مخفی force_hold برای سرور.
$("#forceHoldCheck").on('change', function() {
var checked = $(this).prop('checked');
$("#forceHold").val(checked ? '1' : '');
$("#holdBackwardNotice").toggleClass("is-blocking", !checked);
});
// هر تغییر تاریخ/نوع اقدام ⇒ وضعیت دوباره محاسبه می‌شود.
$("#ActionBox").on('change input', "#visitHoldUntil, #actionType",
function() { syncHoldBackward(); });
// پیکر تاریخ هم بعد از انتخاب، فیلد مخفی را پر می‌کند.
$(document).on('change', ".dateTimePickerHoldUntil, .dateTimePickerHoldUntil-alt",
function() { syncHoldBackward(); });
// گرفتن تأیید پیش از ثبتِ هماهنگیِ «انجام‌شده» (فقط در حالت ایجاد، نه ویرایش).
$("#ActionBox").on('submit', function(event) {
// (۱) در ویرایش: تاریخ عقب‌تر از قرار فعلی ⇒ تا تیک تأیید نزده، ارسال نمی‌شود.
// در «ایجاد»، وظیفه ذخیره می‌شود و فقط تاریخ قرار عقب نمی‌رود؛ سرور
// همین را با پیام روشن به کاربر می‌گوید، پس ارسال بلاک نمی‌شود.
if (isEditMode && holdIsBackward() && !$("#forceHoldCheck").prop('checked')) {
event.preventDefault();
var notice = $("#holdBackwardNotice").removeClass("is-hidden").addClass("is-blocking");
if (notice[0].scrollIntoView) {
notice[0].scrollIntoView({ block: 'center', behavior: 'smooth' });
}
notice.trigger('focus');
return;
}
// (۲) هماهنگیِ «انجام‌شده»: تأیید یک‌بارهٔ انجام کار.
if (isEditMode || holdConfirmed) {
return; // ویرایش، یا تأیید قبلی ⇒ ارسال آزاد
}
@@ -704,6 +848,7 @@
// مقدار ذخیره‌شدهٔ «قرار بعدی» را در همان فیلد نشان بده (بعد از toggle،
// چون toggle برای انواع غیرهماهنگی فیلد را پاک می‌کند).
seedHoldPicker(customerHold);
syncHoldBackward();
$("#desc").val(ActionParseData[0]['fields']['desc']);
$("#userID").val(ActionParseData[0]['fields']['forUser']);
if(ActionParseData[0]['fields']['status'] == 'finish'){
@@ -752,6 +897,11 @@
enabled: false,
}
},
// بعد از انتخاب تاریخ، وضعیت «عقب‌تر بودن» فوراً بازمحاسبه شود
// (پیکر مقدار altField را برنامه‌نویسی‌شده می‌نویسد و ممکن است change رخ ندهد).
onSelect: function () {
setTimeout(function () { syncHoldBackward(); }, 0);
},
});
// اگر پیکر ساخته نشد (مثلاً نام کلاس در قالب عوض شده باشد) هشدار بده؛
@@ -7,7 +7,7 @@
<div class="columns m-0">
<div class="column is-12 px-2 pt-3 pb-0">
{% for message in messages %}
<div class="notification {% if message.tags == 'error' %}is-danger{% elif message.tags == 'success' %}is-success{% else %}is-info{% endif %} is-light mb-2">
<div class="notification {% if message.tags == 'error' %}is-danger{% elif message.tags == 'warning' %}is-warning{% elif message.tags == 'success' %}is-success{% else %}is-info{% endif %} is-light mb-2">
<button class="delete close-notification" type="button"></button>
<strong>{{ message }}</strong>
</div>
+114 -26
View File
@@ -8,6 +8,7 @@ from django.contrib import messages
from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required
from django.utils import timezone
from persiantools.jdatetime import JalaliDate
from ..models import Action, Customer
from ..sms.triggers import notify_visit_scheduled
@@ -91,6 +92,8 @@ def _action_payload_from_request(request):
'finishDateTime': _parse_form_datetime(request.POST.get('FinishDateTime')),
# فقط برای «هماهنگی» معنا دارد؛ تاریخِ «تا کِی پیگیری نشود».
'holdRaw': _parse_form_datetime(request.POST.get('visitHoldUntil')),
# تأیید صریح کاربر برای «عقب بردن» تاریخ قرار دستی.
'force_hold': bool(request.POST.get('force_hold')),
'desc': request.POST.get('desc', None),
'result': request.POST.get('result', None),
'userID': request.POST.get('userID', None),
@@ -98,13 +101,13 @@ def _action_payload_from_request(request):
}
def _apply_visit_hold(customer_obj, payload, *, status, is_new):
def _apply_visit_hold(customer_obj, payload, *, status, is_new, force_hold=False):
"""هم‌راستاکردن «قرار بعدی» با اقدام ثبت/ویرایش‌شده.
قواعد:
* برای اقدام نوع «هماهنگی»:
- تاریخ قرار داده شده ⇒ ``set_visit_hold`` (فقط رو به جلو)
- تاریخ قرار داده شده ⇒ ``set_visit_hold`` (فقط رو به جلو، مگر تأیید کاربر)
- «انجام شد» تیک خورده و تاریخ خالی ⇒ پیش‌فرض ۱۴ روز
- لغو شده ⇒ (در ایجاد) پاک می‌شود
* برای **هر اقدام دیگری** (از جمله «بازدید»):
@@ -114,18 +117,77 @@ def _apply_visit_hold(customer_obj, payload, *, status, is_new):
هماهنگیِ *جدیدتر* آمده باشد؛ پس دست نمی‌زنیم. اعتبارسنجی
:func:`manual_holds_map` (شرط «آخرین فعالیت = هماهنگی») خودش تصمیم
می‌گیرد. بدون این تفکیک، ویرایش یک اقدام قدیمی قرار معتبر را پاک می‌کرد.
خروجی (دیکشنری، برای بازخورد شفاف به کاربر)::
{'ok': bool, 'reason': 'written'|'default'|'forward_only'|'cleared'|'',
'current': datetime|None, 'target': datetime|None}
``reason='forward_only'`` یعنی تاریخ درخواستی از قرار فعلی عقب‌تر است و
نوشته **نشد**؛ لایهٔ ویو باید همین را با تاریخ‌ها به کاربر نشان بدهد و اگر
خودش تأیید کرد، با ``force_hold=True`` دوباره صدا بزند.
"""
result = {'ok': True, 'reason': '', 'current': None, 'target': None,
'force_allowed': not is_new}
# «عقب‌بردن قرار» فقط در ویرایش مجاز است. در ایجادِ وظیفهٔ تازه، کاربر قصد
# اصلاح قرارِ موجود را ندارد و اجازه دادن به آن می‌تواند قولِ قبلیِ ثبت‌شده
# (شاید توسط کارشناس دیگر) را بی‌آنکه کسی بخواهد کوتاه کند.
force_hold = bool(force_hold) and not is_new
is_coordination = payload['actionType'] == Action.ActionType.HAMAHANGI
if not is_coordination or status == Action.Status.CANCELL:
return clear_visit_hold(customer_obj) if is_new else True
if is_new:
result['ok'] = bool(clear_visit_hold(customer_obj))
result['reason'] = 'cleared' if result['ok'] else ''
return result
raw = payload.get('holdRaw')
if raw is not None:
set_visit_hold(customer_obj, normalize_hold_until(raw))
return True
if payload['status'] == Action.Status.FINISH:
set_visit_hold(customer_obj, default_hold_until())
return True
target = normalize_hold_until(raw)
reason = 'written'
elif payload['status'] == Action.Status.FINISH:
target = default_hold_until()
reason = 'default'
else:
return result
current = as_aware(customer_obj.visit_hold_until)
result['current'] = current
result['target'] = target
written = set_visit_hold(customer_obj, target, force=force_hold)
result['ok'] = written
result['reason'] = reason if written else 'forward_only'
return result
def _jalali_day(value):
"""تاریخ شمسی روز (به وقت محلی) برای پیام‌های کاربر."""
if value is None:
return '—'
return JalaliDate(timezone.localtime(value)).strftime('%Y/%m/%d')
def _report_hold_result(request, result):
"""بازخورد شفافِ نتیجهٔ نوشتن «تا تاریخ پیگیری نشود» به کاربر.
قاعدهٔ «فقط رو به جلو» بی‌صدا نیست: اگر تاریخ درخواستی از قرار فعلی عقب‌تر
باشد، اینجا گفته می‌شود که ذخیره نشد و کاربر چطور می‌تواند خودش انجامش دهد.
"""
if result.get('reason') != 'forward_only':
return
current, target = result.get('current'), result.get('target')
tz = timezone.get_current_timezone()
if current is not None and target is not None \
and target.astimezone(tz).date() >= current.astimezone(tz).date():
# همان روز ⇒ تغییر واقعی‌ای لازم نیست؛ پیام دادن گمراه‌کننده است.
return
tail = ('برای ثبت، گزینهٔ «تاریخ قرار را عقب ببر» را تیک بزنید.'
if result.get('force_allowed')
else 'برای تغییر قرار، همان وظیفهٔ هماهنگی را ویرایش کنید.')
messages.warning(
request,
'تاریخ قرار ذخیره نشد؛ از قرار فعلی این مشتری (%s) عقب‌تر است. %s'
% (_jalali_day(current), tail)
)
def _reject(request, error, redirect_to):
@@ -164,7 +226,11 @@ def customerNewAction(request ,c_id):
# last_bazdid_dt فقط از تابع واحد بازمحاسبه می‌شود؛ نه از finishDateTime.
refresh_last_bazdid(customer_obj, reason=REASON_CREATE, user=request.user)
# «قرار بعدی» فقط برای هماهنگی.
_apply_visit_hold(customer_obj, payload, status=newAction.status, is_new=True)
hold_result = _apply_visit_hold(
customer_obj, payload, status=newAction.status, is_new=True,
force_hold=payload.get('force_hold', False),
)
_report_hold_result(request, hold_result)
# پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
if newAction.actionType == Action.ActionType.BAZDID:
@@ -225,7 +291,11 @@ def customerEditAction(request ,c_id, action_id):
# «قرار بعدی»: با ویرایش هم‌راستا می‌شود. برای اقدام غیرهماهنگی عمداً
# پاک نمی‌کنیم؛ رکورد ویرایش‌شده ممکن است قدیمی‌تر از یک هماهنگیِ جدیدتر
# باشد و پاک‌کردن، قرار معتبر را از بین ببرد.
_apply_visit_hold(customer_obj, payload, status=newAction.status, is_new=False)
hold_result = _apply_visit_hold(
customer_obj, payload, status=newAction.status, is_new=False,
force_hold=payload.get('force_hold', False),
)
_report_hold_result(request, hold_result)
#return redirect('/panel/customer/'+str(c_id)+'/action')
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
@@ -272,14 +342,35 @@ def ActionListAll(request):
data = {'actions': actions, 'allUsers': allUsers}
return render(request, 'admin_panel/ActionList.html', data)
def _local_day_range(day=None):
"""بازهٔ ``[آغاز روز، پایان روز]`` به وقت محلی و aware.
قبلاً این دو ویو رشته‌های naive می‌ساختند (``'%Y-%m-%d 00:00:00'``) و Django
آن‌ها را با منطقهٔ زمانی پیش‌فرض تفسیر می‌کرد؛ هر تغییر ساعت/تایم‌زون باعث
جابه‌جایی مرز «وظایف امروز» می‌شد. حالا مرز صریح و aware است.
در روزهای تغییر ساعت (مثلاً ۲۲ مارس در تهران) نیمه‌شب ممکن است وجود نداشته
باشد؛ آن حالت با ``is_dst=False`` پوشش داده می‌شود تا یک تاریخ مرزی، صفحهٔ
وظایف را نشکند (همان کاری که ``visit_policy.as_aware`` می‌کند).
"""
day = day or timezone.localdate()
bounds = []
for moment in (datetime.datetime.combine(day, datetime.time.min),
datetime.datetime.combine(day, datetime.time.max)):
try:
bounds.append(timezone.make_aware(moment))
except Exception:
bounds.append(timezone.make_aware(moment, is_dst=False))
return bounds[0], bounds[1]
@login_required(login_url='/panel/p_login/')
def ActionListToday(request):
startTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 00:00:00')
endTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 23:59:59')
#startTodayDT = datetime.datetime.strptime(startTodayDT, '%Y-%m-%d-00:00:00')
#endTodayDT = datetime.datetime.strptime(endTodayDT, '%Y-%m-%d-23:59:59')
#return HttpResponse(todayDT)
actions = Action.objects.filter(actionDateTime__gte=startTodayDT,actionDateTime__lte=endTodayDT, forUser__id=request.user.id, status='waite').order_by("-actionDateTime")[:50]
startTodayDT, endTodayDT = _local_day_range()
actions = Action.objects.filter(
actionDateTime__range=(startTodayDT, endTodayDT),
forUser__id=request.user.id, status='waite',
).order_by("-actionDateTime")[:50]
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {'actions': actions, 'allUsers': allUsers}
return render(request, 'admin_panel/ActionList.html', data)
@@ -290,22 +381,19 @@ def ActionListUserToday(request, u_id):
status = request.GET.get('status', None)
unixADT = dateTimeAction
if dateTimeAction:
dateTimeAction = datetime.datetime.fromtimestamp(int(dateTimeAction)/1e3)
startTodayDT = dateTimeAction.strftime('%Y-%m-%d 00:00:00')
endTodayDT= dateTimeAction.strftime('%Y-%m-%d 23:59:59')
# timestamp میلی‌ثانیه‌ای GET → تاریخ محلی (نه naive UTC).
moment = as_aware(datetime.datetime.fromtimestamp(int(dateTimeAction) / 1e3))
day = moment.astimezone(timezone.get_current_timezone()).date()
else:
startTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 00:00:00')
endTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 23:59:59')
#startTodayDT = datetime.datetime.strptime(startTodayDT, '%Y-%m-%d-00:00:00')
#endTodayDT = datetime.datetime.strptime(endTodayDT, '%Y-%m-%d-23:59:59')
#return HttpResponse(todayDT)
day = timezone.localdate()
startTodayDT, endTodayDT = _local_day_range(day)
if status == 'waite' or status == None or status == '' or status == ' ':
status = 'waite'
actions = Action.objects.filter(actionDateTime__gte=startTodayDT,actionDateTime__lte=endTodayDT, forUser__id=u_id, status=status).order_by("-actionDateTime")[:50]
actions = Action.objects.filter(actionDateTime__range=(startTodayDT, endTodayDT), forUser__id=u_id, status=status).order_by("-actionDateTime")[:50]
actionPr = Action.objects.filter(actionDateTime__lt=startTodayDT, forUser__id=u_id, status=status).order_by("-actionDateTime")[:200]
elif status == 'finish':
actionPr = None
actions = Action.objects.filter(actionDateTime__gte=startTodayDT,actionDateTime__lte=endTodayDT, forUser__id=u_id, status=status).order_by("-actionDateTime")[:50]
actions = Action.objects.filter(actionDateTime__range=(startTodayDT, endTodayDT), forUser__id=u_id, status=status).order_by("-actionDateTime")[:50]
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {'actions': actions, 'allUsers': allUsers, 'u_id': u_id, "unixADT": unixADT, "status": status, 'actionPr': actionPr}
+14 -5
View File
@@ -628,7 +628,8 @@ def entezar(request):
entry = sources.get(cid) or {}
if not entry:
return 0
if len(entry) > 1:
days = {value.astimezone(current_tz).date() for value in entry.values()}
if len(days) > 1:
return 4
if SOURCE_MANUAL in entry:
return 1
@@ -699,8 +700,10 @@ def entezar(request):
try:
entezar_bazdid = CacheTable.objects.get(key='9mahbazdid')
# کارت داشبورد = «کارِ فوری» = تعیین تکلیف نشده.
entezar_bazdid.value = counts['uncoordinated']
entezar_bazdid.save()
# فقط در صورت تغییر می‌نویسیم تا یک GET ساده بی‌دلیل دیتابیس را تغییر ندهد.
if str(entezar_bazdid.value) != str(counts['uncoordinated']):
entezar_bazdid.value = str(counts['uncoordinated'])
entezar_bazdid.save(update_fields=['value'])
except CacheTable.DoesNotExist:
pass
@@ -812,14 +815,20 @@ def entezar(request):
entry.update(followup_entry)
flags = [key for key in (SOURCE_MANUAL, SOURCE_SCHEDULED,
SOURCE_COORDINATION) if key in entry]
# «N قرار» = تعداد **روزهای یکتای** قرار، نه تعداد منابع. یک هماهنگی
# که تاریخ تعویقش همان روز است، هم‌زمان «manual» و «coordination»
# تولید می‌کند ولی دو قرارِ متفاوت نیست (برچسب «۲ قرار» گمراه‌کننده بود).
appointment_days = {
entry[key].astimezone(current_tz).date() for key in flags
}
appointment_rows[customer.id] = {
'manual': entry.get(SOURCE_MANUAL),
'scheduled': entry.get(SOURCE_SCHEDULED),
'coordination': entry.get(SOURCE_COORDINATION),
'overdue_days': overdue_days.get(customer.id, 0),
'last_visit': visits.get(customer.id),
# تعداد منابع (۰ تا ۳) — قالب بر اساس آن برچسب وضعیت را می‌سازد.
'source_count': len(flags),
# تعداد قرارهای یکتا (۱ تا ۳ روز متمایز) — قالب بر اساس آن برچسب می‌سازد.
'source_count': len(appointment_days),
# تاریخ پیگیری این نمای خاص (برای ستون اختصاصی نمای «امروز»).
'followup_at': (min(followup_entry.values())
if followup_entry else None),
+75 -12
View File
@@ -337,6 +337,14 @@ SOURCE_MANUAL = "manual"
SOURCE_SCHEDULED = "scheduled"
SOURCE_COORDINATION = "coordination"
# اقدام‌هایی که **واقعاً** یک «قرار بعدی» را جایگزین می‌کنند.
# چرا فقط این‌ها؟ قرار دستی یعنی «تا این تاریخ تماس نگیر». تنها چیزی که این
# قرار را منتفی می‌کند، خودِ بازدیدی است که به‌جای تماس انجام شده (یا نصب که
# چرخه را از نو شروع می‌کند). یک سند وصول، یک تماس ورودی یا خروجی، یا هر
# اقدام دیگر، قرار را باطل نمی‌کند؛ در غیر این‌صورت قرارِ ثبت‌شده بی‌صدا می‌مرد
# و مشتری دوباره در فهرست تماس ظاهر می‌شد.
_SUPERSEDING_TYPES = (Action.ActionType.BAZDID, Action.ActionType.NASB)
def _future_actions(action_type, statuses, *, now_utc, whole_day=False):
"""اقدام‌های آیندهٔ یک نوع با تاریخ مؤثر (تاریخ اقدام، وگرنه تاریخ اتمام).
@@ -409,10 +417,13 @@ def _valid_manual_holds(holds):
قرارهایی که معتبرند:
۱. یک اقدام «هماهنگی» فعال (``waite``/``finish``) برای مشتری وجود دارد،
۲. و **آخرین فعالیتِ مشتری همان هماهنگی است** — اگر پس از هماهنگی، اقدام
دیگری (به‌ویژه «بازدید») ثبت شده باشد، قرار باطل است.
۲. و **آخرین فعالیتِ جایگزین‌کنندهٔ قرار** همان هماهنگی است — یعنی پس از
هماهنگی، بازدید (``bazdid``) یا نصب (``nasb``) ثبت نشده باشد.
بند ۲ همان چیزی است که «ثبت بازدید پس از هماهنگی» را خودکار بی‌اثر می‌کند.
ولی فقط اقدام‌های جایگزین‌کننده (``_SUPERSEDING_TYPES``) قرار را باطل
می‌کنند: سند وصول، تماس ورودی/خروجی و بقیهٔ اقدام‌ها قرار را نمی‌کشند —
وگرنه یک پرداخت ساده، قرارِ توافق‌شده را بی‌صدا از بین می‌برد.
این تابع جدا شده تا هم :func:`manual_holds_map` (قرارهای آینده) و هم
:func:`followups_on_date` (قرارهای یک روز مشخص، حتی گذشته) از **یک قاعدهٔ
@@ -436,10 +447,12 @@ def _valid_manual_holds(holds):
.annotate(last=Max("createdDT"))
.values_list("customer_id", "last")
)
# آخرین فعالیتِ غیرهماهنگی (بازدید، نصب، تماس، …) برای هر مشتری.
# آخرین فعالیتِ **جایگزین‌کنندهٔ قرار** (بازدید/نصب) برای هر مشتری.
# عمداً فقط این دو نوع: یک سند وصول یا تماس ورودی نباید قرار را باطل کند.
other_latest = dict(
Action.objects.filter(customer_id__in=customer_ids)
.exclude(actionType=Action.ActionType.HAMAHANGI)
Action.objects.filter(
customer_id__in=customer_ids, actionType__in=_SUPERSEDING_TYPES,
)
.values("customer_id")
.annotate(last=Max("createdDT"))
.values_list("customer_id", "last")
@@ -534,7 +547,8 @@ def followups_on_date(target_date, *, now=None):
امروز یا آینده — را می‌بیند تا کارشناس بتواند با تقویم عقب/جلو برود.
خروجی: ``{customer_id: {منبع: تاریخ}}`` مرتب بر اساس زودترین ساعت موعد.
مشتریانی که قرار **بازدید** دارند در خروجی نیستند.
مشتریانی که قرار **بازدید** دارند، یا بازدیدشان از همان روز موعد به بعد
**انجام شده**، در خروجی نیستند (فهرست، «کارِ باقی‌مانده» است نه آرشیو).
"""
day_start, day_end = _local_day_bounds(target_date)
@@ -584,6 +598,25 @@ def followups_on_date(target_date, *, now=None):
for customer_id in _pending_visit_customer_ids(since=day_start):
result.pop(customer_id, None)
# ۴) حذف مشتریانی که بازدیدشان از همان روزِ موعد به بعد **انجام شده** است.
# قاعدهٔ کسب‌وکار: اگر بازدید انجام شده، تماسِ آن روز لازم نیست. قبلاً فقط
# بازدیدِ «در انتظار» دیده می‌شد، پس مشتریِ بازدیدشده باز هم در فهرست
# «نیازمند پیگیری امروز» می‌ماند (۹۲٪ ردیف‌های دادهٔ واقعی).
# فقط برای امروز/آینده اعمال می‌شود تا مرور روزهای گذشته (تقویم) سندِ کاریِ
# همان روز را با داده‌های بعدی بازنویسی نکند.
if result:
now = _now(now)
current_tz = timezone.get_current_timezone()
if target_date >= now.astimezone(current_tz).date():
visits = last_visit_map(now=now, customer_ids=list(result))
for customer_id, visited_at in visits.items():
bucket = result.get(customer_id)
if not bucket or visited_at is None:
continue
due_day = min(bucket.values()).astimezone(current_tz).date()
if visited_at.astimezone(current_tz).date() >= due_day:
result.pop(customer_id, None)
return result
@@ -600,6 +633,10 @@ def overdue_followups(*, now=None, target_date=None):
(موعدش تازه است).
* اگر برایش **قرار بازدید** ثبت شده باشد، از فهرست خارج می‌شود — همان قاعدهٔ
:func:`followups_on_date` («بازدید گذاشته شده ⇒ تماس لازم نیست»).
* اگر بازدیدش **از همان موعد به بعد انجام شده** باشد، از فهرست خارج می‌شود
(پیگیری با بازدید بسته شده است).
* اگر **قرار دستیِ معتبر** داشته باشد («تا تاریخ پیگیری نشود»)، از فهرست
خارج می‌شود؛ چون خودِ کاربر صریحاً خواسته تا آن تاریخ تماس نگیریم.
* هماهنگیِ لغوشده (``cancell``) اصلاً دیده نمی‌شود.
خروجی: ``{customer_id: تاریخِ موعدِ ازدست‌رفته}`` — تا هم شمارش کنیم و هم
@@ -641,6 +678,24 @@ def overdue_followups(*, now=None, target_date=None):
for customer_id in pending_visits:
overdue.pop(customer_id, None)
current_tz = timezone.get_current_timezone()
# بازدیدِ **انجام‌شده** از همان موعد به بعد ⇒ این پیگیری انجام شده است.
# قبلاً فقط بازدیدِ «در انتظار» دیده می‌شد و همین، فهرست «از روزهای قبل»
# را پر از مشتریانی می‌کرد که بازدیدشان ثبت شده بود (۸۸٪ دادهٔ واقعی).
visits = last_visit_map(now=now, customer_ids=list(overdue))
for customer_id, visited_at in visits.items():
if visited_at is None:
continue
if visited_at.astimezone(current_tz).date() >= overdue[customer_id]:
overdue.pop(customer_id, None)
# «قرار دستیِ معتبر» یعنی کاربر صریحاً گفته «تا این تاریخ پیگیری نشود»؛
# چنین مشتری‌ای نمی‌تواند هم‌زمان «عقب‌افتاده» شمرده شود. قبلاً این ستون
# در این محاسبه دیده نمی‌شد (۳۸ ردیف متناقض در دادهٔ واقعی).
for customer_id in manual_holds_map(now=now):
overdue.pop(customer_id, None)
return overdue
@@ -726,12 +781,20 @@ def default_hold_until(*, now=None, days=HOLD_DEFAULT_DAYS):
return end_of_local_day(base)
def set_visit_hold(customer, until, *, now=None):
"""ثبت قرار دستی؛ فقط اگر **جلوتر** از مقدار فعلی باشد می‌نویسد.
def set_visit_hold(customer, until, *, now=None, force=False):
"""ثبت قرار دستی؛ به‌طور پیش‌فرض فقط اگر **جلوتر** از مقدار فعلی باشد می‌نویسد.
قاعدهٔ «فقط رو به جلو» (مثل ``refresh_last_bazdid``) جلوی این را می‌گیرد که
ثبت هماهنگی توسط کارشناس دوم، قرارِ دورترِ قبلی را عقب بکشد.
خروجی: ``True`` اگر نوشته شد.
قاعدهٔ «فقط رو به جلو» جلوی این را می‌گیرد که ثبت هماهنگی توسط کارشناس دوم،
قرارِ دورترِ قبلی را عقب بکشد.
``force=True`` برای وقتی است که کاربر **خودش** صریحاً تأیید کرده تاریخ عقب
برود (تیک «تاریخ قرار را عقب ببر» در مودالِ **ویرایش** ⇒ فیلد ``force_hold``).
در «ایجاد وظیفهٔ تازه» ویو این پارامتر را نمی‌فرستد. این پارامتر فقط از مسیر
ویو و پس از تأیید کاربر استفاده می‌شود؛ منطق نمایش/فهرست‌ها تغییری نمی‌کند و
ستون همچنان همان جای ذخیرهٔ تاریخ است.
خروجی: ``True`` اگر نوشته شد، وگرنه ``False`` (یا مقدار فعلی جلوتر است، یا
تاریخ نامعتبر).
"""
if customer is None or customer.pk is None:
return False
@@ -739,7 +802,7 @@ def set_visit_hold(customer, until, *, now=None):
if target is None:
return False
current = as_aware(customer.visit_hold_until)
if current is not None and current >= target:
if current is not None and current >= target and not force:
return False
Customer.objects.filter(pk=customer.pk).update(
visit_hold_until=target, visit_hold_set_at=_now(now),