diff --git a/admin_panel/templates/admin_panel/customerActionInc.html b/admin_panel/templates/admin_panel/customerActionInc.html index 4ae8889..f308032 100644 --- a/admin_panel/templates/admin_panel/customerActionInc.html +++ b/admin_panel/templates/admin_panel/customerActionInc.html @@ -62,7 +62,9 @@ {% endif %} {% for action in actions %} -
+
{% ifequal action.status 'finish' %} @@ -113,7 +115,9 @@

کارهای مانده از روزهای قبل

{% for action in actionPr %} -
+
{% ifequal action.status 'finish' %} @@ -474,6 +478,26 @@ // مقدار undefined می‌شود. بدون این محافظ، یک ناهم‌خوانی نام کلاس باعث // TypeError می‌شد و بقیهٔ منطقِ هندلر (پنهان‌کردن فیلدها، برداشتن تیک خودکار) // اجرا نمی‌شد. + // پرکردن پیکر «تا تاریخ پیگیری نشود» از مقدار ذخیره‌شدهٔ مشتری. + // بدون این، هنگام بازکردن مجدد یک اقدام، فیلد همیشه خالی دیده می‌شد + // (مقدار در دیتابیس بود ولی مودال آن را بارگذاری نمی‌کرد). + function seedHoldPicker(isoValue) { + var box = $("#visitHoldUntil"); + if (!isoValue) { + resetHoldPicker(); + return; + } + var ms = new Date(isoValue).getTime(); + if (isNaN(ms)) { + resetHoldPicker(); + return; + } + if (dateTimePickerHoldUntil && typeof dateTimePickerHoldUntil.setDate === 'function') { + dateTimePickerHoldUntil.setDate(ms); + } + box.val(String(ms)); + } + function resetHoldPicker() { if (dateTimePickerHoldUntil && typeof dateTimePickerHoldUntil.setDate === 'function') { dateTimePickerHoldUntil.setDate(); @@ -576,8 +600,10 @@ isEditMode = true; $("#sabtBtn05").val("ویرایش"); var actionEditUrl = "{% url 'admin_panel:customerEditAction' 1111 2222 %}"; - var action_id = $(this).parent().attr("action_id"); - var customer_id = $(this).parent().attr("customer_id"); + var row = $(this).parent(); + var action_id = row.attr("action_id"); + var customer_id = row.attr("customer_id"); + var customerHold = row.attr("customer_hold") || ""; actionEditUrl = actionEditUrl.replace('1111', customer_id); actionEditUrl = actionEditUrl.replace('2222', action_id); $("#ActionBox").prop("action", actionEditUrl); @@ -602,6 +628,9 @@ // فیلد «تا تاریخ پیگیری نشود» با نوع اقدام هم‌راستا شود (در حالت ویرایش هم). closeHoldHelp(); toggleCoordinationField(ActionParseData[0]['fields']['actionType']); + // مقدار ذخیره‌شدهٔ «قرار بعدی» را در همان فیلد نشان بده (بعد از toggle، + // چون toggle برای انواع غیرهماهنگی فیلد را پاک می‌کند). + seedHoldPicker(customerHold); $("#desc").val(ActionParseData[0]['fields']['desc']); $("#userID").val(ActionParseData[0]['fields']['forUser']); if(ActionParseData[0]['fields']['status'] == 'finish'){ diff --git a/admin_panel/templates/admin_panel/entezar.html b/admin_panel/templates/admin_panel/entezar.html index 14897ca..5b28d8c 100644 --- a/admin_panel/templates/admin_panel/entezar.html +++ b/admin_panel/templates/admin_panel/entezar.html @@ -24,7 +24,7 @@ -webkit-overflow-scrolling: touch; } @media (min-width: 1024px) { - .ez-stats { grid-auto-flow: row; grid-template-columns: repeat(5, 1fr); overflow: visible; } + .ez-stats { grid-auto-flow: row; grid-template-columns: repeat(6, 1fr); overflow: visible; } } .ez-stat { display: flex; flex-direction: column; gap: .18rem; @@ -40,6 +40,7 @@ .ez-stat.success { border-top-color: #16a34a; } .ez-stat.warning { border-top-color: #d97706; } .ez-stat.link { border-top-color: #0284c7; } + .ez-stat.violet { border-top-color: #7c3aed; } .ez-stat-top { display: flex; align-items: baseline; justify-content: space-between; gap: .4rem; @@ -128,6 +129,7 @@ .ez-badge.success { background: #dcfce7; color: #15803d; } .ez-badge.warning { background: #fef3c7; color: #b45309; } .ez-badge.mixed { background: #e0f2fe; color: #0369a1; } + .ez-badge.coordination { background: #ede9fe; color: #6d28d9; } /* تأخیر به‌صورت چیپ (خوانا در یک نگاه) */ .ez-late { @@ -202,10 +204,8 @@ بیش از {{ counts.period_months }} ماه گذشته باشد. روی هر کارت بزنید تا فقط همان گروه نمایش داده شود؛ کل = بدون قرار + با قرار. - {% if counts.both %} - ({{ counts.both }} مشتری هم قرار هماهنگی دارند و هم بازدید - برنامه‌ریزی‌شده، پس در هر دو کارت شمرده می‌شوند.) - {% endif %} + سه کارتِ آخر سه نوع قرارِ جدا هستند؛ یک مشتری می‌تواند بیش از + یکی داشته باشد، پس جمع آن‌ها ممکن است از «با قرار» بیشتر شود.

@@ -259,6 +259,16 @@ اقدام «بازدید» با تاریخ آینده ثبت شده است + + + + هماهنگی + {{ counts.coordination }} + + تماس گرفته شده و پیگیری به آینده موکول شده است +
{# تفکیک مدت تأخیر در نمای جاری #} @@ -293,21 +303,23 @@
- - + + + - + + - - + + @@ -328,23 +340,26 @@ {% with appt=appointment_rows|row_for:customer.id %} - + {% empty %} - diff --git a/admin_panel/views/actions.py b/admin_panel/views/actions.py index 00e1bfe..37178aa 100644 --- a/admin_panel/views/actions.py +++ b/admin_panel/views/actions.py @@ -91,21 +91,26 @@ def _action_payload_from_request(request): } -def _apply_visit_hold(customer_obj, payload, *, status): +def _apply_visit_hold(customer_obj, payload, *, status, is_new): """هم‌راستاکردن «قرار بعدی» با اقدام ثبت/ویرایش‌شده. - قواعد (یکسان برای ایجاد و ویرایش — قبلاً ایجاد این را انجام نمی‌داد): + قواعد: - * فقط برای اقدام نوع «هماهنگی» معنا دارد؛ - - اگر تاریخ قرار داده شده باشد ⇒ ``set_visit_hold`` (فقط رو به جلو) - - اگر «انجام شد» تیک خورده و تاریخ خالی است ⇒ پیش‌فرض ۱۴ روز - - اگر هماهنگی لغو شده باشد ⇒ قرار پاک می‌شود - * **هر اقدام دیگری** (از جمله «بازدید») ⇒ قرار دستی پاک می‌شود؛ چون هدفِ - قرار (پیگیری در آینده) با انجام/برنامه‌ریزی بازدید منتفی است. + * برای اقدام نوع «هماهنگی»: + - تاریخ قرار داده شده ⇒ ``set_visit_hold`` (فقط رو به جلو) + - «انجام شد» تیک خورده و تاریخ خالی ⇒ پیش‌فرض ۱۴ روز + - لغو شده ⇒ (در ایجاد) پاک می‌شود + * برای **هر اقدام دیگری** (از جمله «بازدید»): + - در حالت **ایجاد**، این اقدام آخرین فعالیت مشتری است ⇒ قرار دستی پاک + می‌شود (سناریوی گزارش‌شده: ثبت بازدید پس از هماهنگی). + - در حالت **ویرایش**، رکوردی تاریخی است و ممکن است قرارِ فعلی از یک + هماهنگیِ *جدیدتر* آمده باشد؛ پس دست نمی‌زنیم. اعتبارسنجی + :func:`manual_holds_map` (شرط «آخرین فعالیت = هماهنگی») خودش تصمیم + می‌گیرد. بدون این تفکیک، ویرایش یک اقدام قدیمی قرار معتبر را پاک می‌کرد. """ is_coordination = payload['actionType'] == Action.ActionType.HAMAHANGI if not is_coordination or status == Action.Status.CANCELL: - return clear_visit_hold(customer_obj) + return clear_visit_hold(customer_obj) if is_new else True raw = payload.get('holdRaw') if raw is not None: @@ -152,7 +157,7 @@ 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) + _apply_visit_hold(customer_obj, payload, status=newAction.status, is_new=True) # پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید. if newAction.actionType == Action.ActionType.BAZDID: @@ -210,9 +215,10 @@ def customerEditAction(request ,c_id, action_id): # (تا مقداری که این رکورد ساخته بود با منبع حقیقت هم‌راستا شود). reason = REASON_CANCEL if (was_done and not payload['is_done']) else REASON_EDIT refresh_last_bazdid(customer_obj, reason=reason, user=request.user) - # «قرار بعدی»: با ویرایش هم‌راستا می‌شود؛ اگر اقدام دیگر هماهنگی نباشد یا - # هماهنگی لغو شود، قرار دستی پاک می‌شود. - _apply_visit_hold(customer_obj, payload, status=newAction.status) + # «قرار بعدی»: با ویرایش هم‌راستا می‌شود. برای اقدام غیرهماهنگی عمداً + # پاک نمی‌کنیم؛ رکورد ویرایش‌شده ممکن است قدیمی‌تر از یک هماهنگیِ جدیدتر + # باشد و پاک‌کردن، قرار معتبر را از بین ببرد. + _apply_visit_hold(customer_obj, payload, status=newAction.status, is_new=False) #return redirect('/panel/customer/'+str(c_id)+'/action') return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found')) diff --git a/admin_panel/views/customers.py b/admin_panel/views/customers.py index 3731f1b..9d25ee0 100644 --- a/admin_panel/views/customers.py +++ b/admin_panel/views/customers.py @@ -24,7 +24,8 @@ from ..visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED # نماهای صفحهٔ «انتظار» (فیلتر ?view=). پیش‌فرض = فقط «بدون قرار» تا رفتار # قبلی صفحه برای کاربران حفظ شود. -ENTEZAR_VIEWS = ('uncoordinated', 'manual', 'scheduled', 'coordinated', 'all') +ENTEZAR_VIEWS = ('uncoordinated', 'manual', 'scheduled', 'coordination', + 'coordinated', 'all') ENTEZAR_DEFAULT_VIEW = 'uncoordinated' # حالت‌های مرتب‌سازی (?sort=): # oldest ⇒ قدیمی‌ترین بازدید اول (پیش‌فرض/اولویت کارشناس) @@ -341,18 +342,17 @@ def customerEdit(request, c_id): @login_required(login_url='/panel/p_login/') def entezar(request): - """فهرست «منتظر بازدید» + تفکیک «با قرار» و «بدون قرار». + """فهرست «منتظر بازدید» + تفکیک بر اساس **نوع قرار**. - منبع حقیقت تاریخ بازدید ``visit_policy.last_visit_map`` است و منبع «قرار بعدی» - ``appointment_sources_map`` (سه منبع: قرار دستی، بازدید برنامه‌ریزی‌شده، - هماهنگیِ در انتظار). هر مشتری دقیقاً در یکی از چهار گروه زیر است: + منبع حقیقت تاریخ بازدید ``visit_policy.last_visit_map`` است و منبع قرارها + ``appointment_sources_map`` که سه منبع مستقل دارد: - * ``A`` — معوق بدون هیچ قرار (پیش‌فرض نمایش؛ رفتار قبلی صفحه) - * ``B`` — معوق با قرار دستی (تعویق از فرم هماهنگی) - * ``C`` — معوق با بازدید برنامه‌ریزی‌شده یا هماهنگی در انتظار - * ``D`` — معوق با بیش از یک منبعِ قرار + * ``manual`` — قرار دستی روی مشتری (تعویق از فرم هماهنگی) + * ``scheduled`` — اقدام «بازدید» با تاریخ آینده + * ``coordination`` — اقدام «هماهنگی» با تاریخ پیگیری در آینده - فیلتر با ``?view=`` و قابل ترکیب با جست‌وجوی آدرس است. + هر فیلتر فقط منبع خودش را نشان می‌دهد (یک مشتری می‌تواند چند منبع داشته + باشد و در چند فیلتر دیده شود). فیلتر با ``?view=`` و قابل ترکیب با جست‌وجو. """ searchVal = request.GET.get('searchVal', '') searchOn = request.GET.get('searchOn', '') @@ -403,29 +403,35 @@ def entezar(request): if visits.get(customer_id) is None or visits[customer_id] <= due_cut ] - # تفکیک چهارگروهی (MECE): هر مشتری فقط در یک گروه. - groups = {'A': [], 'B': [], 'C': [], 'D': []} + # تفکیک بر اساس **منبعِ** قرار: هر مشتری می‌تواند صفر تا سه منبع داشته باشد + # (قرار دستی، بازدید برنامه‌ریزی‌شده، هماهنگی). قبلاً «هماهنگی» با «بازدید» + # در یک گروه جمع می‌شد و در نمای «بازدید برنامه‌ریزی‌شده» دیده می‌شد؛ حالا + # هر منبع گروه نمایشیِ خودش را دارد. + with_manual, with_scheduled, with_coordination = set(), set(), set() for customer_id in stale_ids: customer_sources = sources.get(customer_id) or {} - manual = SOURCE_MANUAL in customer_sources - scheduled = (SOURCE_SCHEDULED in customer_sources - or SOURCE_COORDINATION in customer_sources) - if manual and scheduled: - groups['D'].append(customer_id) - elif manual: - groups['B'].append(customer_id) - elif scheduled: - groups['C'].append(customer_id) - else: - groups['A'].append(customer_id) + if SOURCE_MANUAL in customer_sources: + with_manual.add(customer_id) + if SOURCE_SCHEDULED in customer_sources: + with_scheduled.add(customer_id) + if SOURCE_COORDINATION in customer_sources: + with_coordination.add(customer_id) - selected = { - 'uncoordinated': groups['A'], - 'manual': groups['B'] + groups['D'], - 'scheduled': groups['C'] + groups['D'], - 'coordinated': groups['B'] + groups['C'] + groups['D'], + coordinated_ids = [cid for cid in stale_ids if cid in ( + with_manual | with_scheduled | with_coordination)] + selected_map = { + # نداشتن هیچ قرار + 'uncoordinated': [cid for cid in stale_ids if cid not in ( + with_manual | with_scheduled | with_coordination)], + # هر مشتری که این منبع را دارد (حتی اگر منبع دیگری هم داشته باشد) + 'manual': [cid for cid in stale_ids if cid in with_manual], + 'scheduled': [cid for cid in stale_ids if cid in with_scheduled], + 'coordination': [cid for cid in stale_ids if cid in with_coordination], + # اجتماع هر سه منبع + 'coordinated': coordinated_ids, 'all': stale_ids, - }[view] + } + selected = selected_map[view] # «مدت تأخیر» = از چه زمانی بازدید عقب افتاده است (به روز). # مبنای محاسبه «موعد بازدید» است، نه خود بازدید: موعد = بازدید + دوره. @@ -491,17 +497,14 @@ def entezar(request): counts = { 'total': len(stale_ids), - 'uncoordinated': len(groups['A']), - # توجه: عددِ هر کارت باید **همان چیزی** باشد که با کلیک روی آن کارت - # دیده می‌شود. فیلتر ``view=manual`` گروه‌های B و D را نشان می‌دهد و - # ``view=scheduled`` گروه‌های C و D را؛ پس اینجا هم همان اجتماع - # شمرده می‌شود (قبلاً فقط B/C شمرده می‌شد و عدد با فهرست نمی‌خواند). - 'manual': len(groups['B']) + len(groups['D']), - 'scheduled': len(groups['C']) + len(groups['D']), - 'manual_only': len(groups['B']), - 'scheduled_only': len(groups['C']), - 'both': len(groups['D']), - 'coordinated': len(groups['B']) + len(groups['C']) + len(groups['D']), + 'uncoordinated': len(selected_map['uncoordinated']), + # عددِ هر کارت دقیقاً همان چیزی است که با کلیک روی آن دیده می‌شود؛ + # چون یک مشتری می‌تواند چند منبع داشته باشد، جمع کارت‌ها ممکن است + # از «با قرار» بیشتر شود. در راهنمای بالای صفحه توضیح داده می‌شود. + 'manual': len(selected_map['manual']), + 'scheduled': len(selected_map['scheduled']), + 'coordination': len(selected_map['coordination']), + 'coordinated': len(selected_map['coordinated']), 'selected': len(selected), 'buckets': buckets, # برای متن راهنمای بالای صفحه («بیش از N ماه از بازدید گذشته»). @@ -524,19 +527,16 @@ def entezar(request): appointment_rows = {} for customer in customerHa.object_list: entry = sources.get(customer.id) or {} + flags = [key for key in (SOURCE_MANUAL, SOURCE_SCHEDULED, + SOURCE_COORDINATION) if key in entry] appointment_rows[customer.id] = { 'manual': entry.get(SOURCE_MANUAL), 'scheduled': entry.get(SOURCE_SCHEDULED), 'coordination': entry.get(SOURCE_COORDINATION), 'overdue_days': overdue_days.get(customer.id, 0), 'last_visit': visits.get(customer.id), - 'group': ( - 'D' if (SOURCE_MANUAL in entry and ( - SOURCE_SCHEDULED in entry or SOURCE_COORDINATION in entry)) - else 'B' if SOURCE_MANUAL in entry - else 'C' if (SOURCE_SCHEDULED in entry or SOURCE_COORDINATION in entry) - else 'A' - ), + # تعداد منابع (۰ تا ۳) — قالب بر اساس آن برچسب وضعیت را می‌سازد. + 'source_count': len(flags), } return render(request, 'admin_panel/entezar.html', { diff --git a/docs/visit_hold_entezar.md b/docs/visit_hold_entezar.md index 4f2cbf1..f08d6ea 100644 --- a/docs/visit_hold_entezar.md +++ b/docs/visit_hold_entezar.md @@ -104,7 +104,7 @@ | کنترل | تقویم شمسی (همان `pDatepicker` پروژه) | | پیش‌فرض | اگر خالی بماند و تیک «انجام شد» زده شود ⇒ **+۱۴ روز** | | سقف | **۶ ماه** (اعتبارسنجی سروری) | -| حالت ویرایش | فیلد با نوع اقدام هم‌راستا می‌شود (نمایش/پنهان) | +| حالت ویرایش | فیلد با نوع اقدام هم‌راستا می‌شود (نمایش/پنهان) **و مقدار ذخیره‌شدهٔ مشتری در آن بارگذاری می‌شود** | نکتهٔ مهم: فیلد «تاریخ و ساعتی که کار باید انجام شود» (`dateTimeAnjam`) همچنان «زمان تماس» است و دست نخورده باقی می‌ماند. دو مفهوم، دو فیلد. @@ -177,6 +177,30 @@ اما کادر هشدار برای هماهنگیِ انجام‌شده به‌صورت اطلاعاتی نمایش داده می‌شود. * **باز شدن مجدد مودال**: تیک، هشدار و وضعیت تأیید همیشه ریست می‌شوند. +#### بارگذاری مقدار ذخیره‌شده در حالت ویرایش + +هر ردیف اقدام، مقدار فعلی `Customer.visit_hold_until` را به‌صورت ISO در +صفت `customer_hold` نگه می‌دارد. هنگام کلیک روی ردیف (باز کردن فرم ویرایش)، +تابع `seedHoldPicker()` آن را به تقویم شمسی و فیلد مخفی `visitHoldUntil` +می‌نویسد. + +> **باگی که این بخش رفع می‌کند:** قبلاً فرم ویرایش مقدار را از دیتابیس +> نمی‌خواند. مقدار در سرور **درست ذخیره می‌شد** ولی چون فیلد هنگام باز شدن +> مجدد خالی نمایش داده می‌شد، کاربر فکر می‌کرد «ثبت نشده است». + +ترتیب کار مهم است: `seedHoldPicker()` **بعد از** `toggleCoordinationField()` +صدا زده می‌شود، چون برای انواع غیرهماهنگی، `toggle` فیلد را پاک می‌کند. + +#### ایمنی ویرایش رکوردهای قدیمی + +در حالت **ویرایش**، تغییر نوع اقدام به غیرهماهنگی، قرار دستی را پاک +**نمی‌کند**؛ زیرا رکورد ویرایش‌شده ممکن است قدیمی‌تر از یک هماهنگیِ جدیدتر +باشد و پاک‌کردن، قرار معتبر را از بین می‌برد. اعتبارسنجی `manual_holds_map` +(شرط «آخرین فعالیت = هماهنگی») خودش قرار را بی‌اثر می‌کند. + +در حالت **ایجاد** برعکس است: اقدام تازه آخرین فعالیت مشتری است، پس ثبت +بازدید جدید قرار قبلی را پاک می‌کند. + ### ۴.۳ راهنمای بازشونده (فقط «هماهنگی») زیر فیلد تاریخ پیگیری، یک دکمهٔ راهنما با آیکون `!` قرار دارد که پنل توضیحات را @@ -231,40 +255,51 @@ visit_hold_until = max(مقدار فعلی، تاریخ جدید) ## ۵. صفحهٔ انتظار: چهار گروه و فیلترها -هر مشتری معوق **دقیقاً در یکی** از این چهار گروه است: +تفکیک بر اساس **نوعِ منبع** است، نه «چند گروه ثابت». هر مشتری می‌تواند صفر تا +سه منبع داشته باشد: -| گروه | تعریف | -|---|---| -| **A** | معوق، بدون هیچ قرار | -| **B** | معوق، فقط قرار دستی (`manual`) | -| **C** | معوق، فقط بازدید برنامه‌ریزی‌شده یا هماهنگی در انتظار | -| **D** | معوق، هم قرار دستی و هم بازدید برنامه‌ریزی‌شده | +| منبع | تعریف | فیلتر | +|---|---|---| +| `manual` | ستون `visit_hold_until` (تعویق از فرم هماهنگی) | `?view=manual` | +| `scheduled` | اقدام `bazdid` + `waite` با تاریخ آینده | `?view=scheduled` | +| `coordination` | اقدام `hamahangi` (waite/finish) با تاریخ آینده | `?view=coordination` | + +> **باگ قبلی:** `coordination` با `scheduled` در یک گروه جمع می‌شد؛ نتیجه اینکه +> مشتریانی که فقط «هماهنگی» داشتند در نمای «بازدید برنامه‌ریزی‌شده» دیده +> می‌شدند و برچسب «بازدید برنامه‌ریزی‌شده» می‌گرفتند. حالا هر منبع فیلتر و +> ستون خودش را دارد. ### فیلترها (قابل بوکمارک و ترکیب با جست‌وجو) | URL | نمایش | شامل | |---|---|---| -| `/panel/entezar/` (بدون پارامتر) | **پیش‌فرض = فقط A** (رفتار قبلی صفحه) | A | -| `?view=uncoordinated` | بدون قرار | A | -| `?view=manual` | قرار هماهنگی | B + D | -| `?view=scheduled` | بازدید برنامه‌ریزی‌شده | C + D | -| `?view=coordinated` | همهٔ دارای قرار | B + C + D | -| `?view=all` | همهٔ معوق‌ها | A + B + C + D | -| مقدار نامعتبر | برگشت به پیش‌فرض، **بدون خطا** | A | +| `/panel/entezar/` (بدون پارامتر) | **پیش‌فرض = بدون قرار** (رفتار قبلی) | بدون هیچ منبع | +| `?view=uncoordinated` | بدون قرار | بدون هیچ منبع | +| `?view=manual` | قرار دستی | هر مشتری با `manual` | +| `?view=scheduled` | بازدید برنامه‌ریزی‌شده | هر مشتری با `scheduled` | +| `?view=coordination` | هماهنگی | هر مشتری با `coordination` | +| `?view=coordinated` | همهٔ دارای قرار | اجتماع هر سه منبع | +| `?view=all` | همهٔ معوق‌ها | همه | +| مقدار نامعتبر | برگشت به پیش‌فرض، **بدون خطا** | بدون هیچ منبع | + +چون یک مشتری می‌تواند چند منبع داشته باشد، در چند فیلتر دیده می‌شود؛ اما در +`?view=coordinated` فقط **یک‌بار** شمرده می‌شود. به همین دلیل جمع سه کارت منبع +می‌تواند از «با قرار» بیشتر باشد (صفحه این را در راهنمای بالای خود توضیح می‌دهد). ### ستون‌های جدول | ستون | توضیح | |---|---| -| وضعیت قرار | برچسب «با قرار» / «بدون قرار» | -| قرار هماهنگی | تاریخ شمسی قرار دستی (یا —) | -| بازدید برنامه‌ریزی‌شده | تاریخ شمسی قرار `Action` (یا —) | +| وضعیت قرار | «بدون قرار» / «قرار دستی» / «بازدید برنامه» / «هماهنگی» / «N قرار» | +| قرار دستی | تاریخ شمسی قرار دستی (یا —) | +| بازدید برنامه‌ریزی‌شده | تاریخ شمسی اقدام بازدید با تاریخ آینده (یا —) | +| هماهنگی | تاریخ شمسی اقدام هماهنگی با تاریخ آینده (یا —) | | آخرین بازدید | بدون تغییر نسبت به قبل | ### شمارندهٔ داشبورد -`CacheTable('9mahbazdid')` = **گروه A** (کارِ فوری = بدون قرار). هر بار باز شدن -صفحه به‌روز می‌شود. +`CacheTable('9mahbazdid')` = **بدون قرار** (کارِ فوری). هر بار باز شدن صفحه +به‌روز می‌شود. با تغییر تفکیک، این عدد **تغییر نمی‌کند**. --- @@ -461,8 +496,11 @@ python manage.py test tests ## ۹. آنچه تغییر نکرد - منطق «چه کسی عقب‌افتاده است» (همان `last_visit_map` + `due_cut`). -- قاعدهٔ اعتبار قرار دستی (وجود هماهنگی فعال). -- اسکنر پیامک، `recalc_last_bazdid` و چرخهٔ ۹ ماهه. +- قاعدهٔ اعتبار قرار دستی (وجود هماهنگی فعال + آخرین فعالیت بودن). +- `visit_policy.py` به‌طور کامل: `next_appointment_map`، `scheduled_visits_map`، + `coordination_holds_map` — پس **اسکنر پیامک**، `recalc_last_bazdid` و چرخهٔ + ۹ ماهه دقیقاً مثل قبل کار می‌کنند. +- شمارندهٔ داشبورد (`CacheTable('9mahbazdid')` = بدون قرار). - لینک‌های قدیمی `?view=...` و رفتار پیش‌فرض (نمای «بدون قرار»). ## ۱۰. پارامترهای URL @@ -470,9 +508,10 @@ python manage.py test tests ``` /panel/entezar/ نمای پیش‌فرض (بدون قرار، قدیمی‌ترین) ?view=all همهٔ معوق‌ها -?view=coordinated با قرار -?view=manual فقط قرار هماهنگی +?view=coordinated با قرار (اجتماع سه منبع) +?view=manual فقط قرار دستی ?view=scheduled فقط بازدید برنامه‌ریزی‌شده +?view=coordination فقط هماهنگی ?sort=overdue بیشترین تأخیر اول ?sort=newest جدیدترین بازدید اول ?searchVal=رضایی&searchOn=name جست‌وجوی نام diff --git a/tests/test_visit_policy.py b/tests/test_visit_policy.py index fa9f834..c00126a 100644 --- a/tests/test_visit_policy.py +++ b/tests/test_visit_policy.py @@ -804,6 +804,100 @@ class VisitHoldTests(TestCase): def _ms(self, moment): return str(int(moment.timestamp() * 1000)) + def _post_edit(self, customer, action, **payload): + base = {'desc': 'تست', 'userID': self.user.id} + base.update(payload) + for key in [k for k, v in list(base.items()) if v is None]: + base.pop(key) + return self.client.post( + reverse('admin_panel:customerEditAction', + kwargs={'c_id': customer.pk, 'action_id': action.pk}), + base, + HTTP_REFERER='/panel/customer/%d/action' % customer.pk, + ) + + # ---------- ویرایش: تبدیل بازدید به هماهنگی با تاریخ پیگیری ---------- + def test_editing_action_into_coordination_saves_hold(self): + """تبدیل یک اقدام «بازدید» موجود به «هماهنگی» باید تاریخ پیگیری را ذخیره کند.""" + customer = self._customer('تبدیل به هماهنگی') + action = Action.objects.create( + customer=customer, actionType='bazdid', status='waite', + actionDateTime=timezone.now() + timedelta(days=5), + desc='بازدید اولیه', forUser=self.user, + ) + target = timezone.now() + timedelta(days=20) + + response = self._post_edit( + customer, action, + actionType='hamahangi', + dateTimeAnjam=self._ms(timezone.now()), + visitHoldUntil=self._ms(target), + is_done='on', + FinishDateTime=self._ms(timezone.now()), + result='هماهنگ شد', + ) + self.assertEqual(response.status_code, 302) + + customer.refresh_from_db() + action.refresh_from_db() + self.assertEqual(action.actionType, 'hamahangi') + self.assertIsNotNone(customer.visit_hold_until, 'تاریخ پیگیری ذخیره نشد') + self.assertEqual(customer.visit_hold_until.date(), target.date()) + + def test_edit_modal_prefills_saved_hold(self): + """هنگام بازکردن مجدد اقدام، فیلد باید مقدار ذخیره‌شده را نشان دهد. + + رگرسیون: مودال مقدار را از دیتابیس نمی‌خواند، پس همیشه خالی دیده می‌شد + (کاربر فکر می‌کرد «ثبت نشده»). + """ + customer = self._customer('نمایش قرار در ویرایش') + Action.objects.create( + customer=customer, actionType='hamahangi', status='finish', + actionDateTime=timezone.now() - timedelta(minutes=5), + finishDateTime=timezone.now() - timedelta(minutes=5), + desc='هماهنگی', forUser=self.user, + ) + set_visit_hold(customer, timezone.now() + timedelta(days=18)) + + html = self.client.get( + reverse('admin_panel:customerAction', kwargs={'c_id': customer.pk}) + ).content.decode() + # ردیف‌ها مقدار ذخیره‌شده را به‌صورت ISO در خود دارند... + self.assertIn('customer_hold=', html) + self.assertIn('function seedHoldPicker', html) + self.assertIn('seedHoldPicker(customerHold)', html) + + def test_editing_old_visit_does_not_wipe_newer_hold(self): + """ویرایش یک اقدام قدیمی نباید قرار هماهنگیِ جدیدتر را پاک کند.""" + customer = self._customer('ویرایش قدیمی') + old_visit = Action.objects.create( + customer=customer, actionType='bazdid', status='finish', + actionDateTime=timezone.now() - timedelta(days=400), + finishDateTime=timezone.now() - timedelta(days=400), + desc='بازدید قدیمی', forUser=self.user, + ) + Action.objects.create( + customer=customer, actionType='hamahangi', status='finish', + actionDateTime=timezone.now() - timedelta(minutes=5), + finishDateTime=timezone.now() - timedelta(minutes=5), + desc='هماهنگی جدید', forUser=self.user, + ) + set_visit_hold(customer, timezone.now() + timedelta(days=20)) + + # فقط توضیح اقدام قدیمی را عوض می‌کنیم + self._post_edit( + customer, old_visit, + actionType='bazdid', + dateTimeAnjam=self._ms(timezone.now() - timedelta(days=400)), + is_done='on', + FinishDateTime=self._ms(timezone.now() - timedelta(days=400)), + desc='توضیح اصلاح شد', + ) + customer.refresh_from_db() + self.assertIsNotNone(customer.visit_hold_until, + 'ویرایش اقدام قدیمی قرار معتبر را پاک کرد') + self.assertIn(customer.pk, self._ids('manual')) + # ---------- ریسک ۱: تاریخِ «امروز» تا پایان روز معتبر است ---------- def test_hold_until_today_hides_customer_until_end_of_day(self): customer = self._customer('قرار امروز') @@ -1030,13 +1124,11 @@ class VisitHoldTests(TestCase): counts = self._entezar().context['counts'] self.assertEqual(counts['uncoordinated'], 1) # a - # عددِ کارت باید با فهرستی که با کلیک باز می‌شود بخواند: - # view=manual گروه‌های B و D را نشان می‌دهد. + # هر کارت فقط منبع خودش را می‌شمارد؛ d هر دو منبع را دارد ⇒ در هر سه + # کارت شمرده می‌شود (قرار دستی، بازدید، با قرار). self.assertEqual(counts['manual'], 2) # b + d self.assertEqual(counts['scheduled'], 2) # c + d - self.assertEqual(counts['manual_only'], 1) # b - self.assertEqual(counts['scheduled_only'], 1) # c - self.assertEqual(counts['both'], 1) # d + self.assertEqual(counts['coordination'], 0) # هیچ‌کدام هماهنگیِ فعال ندارد self.assertEqual(counts['total'], 4) self.assertEqual(counts['coordinated'], 3) @@ -1044,6 +1136,7 @@ class VisitHoldTests(TestCase): self.assertEqual(self._ids('all'), {a.pk, b.pk, c.pk, d.pk}) self.assertEqual(self._ids('manual'), {b.pk, d.pk}) self.assertEqual(self._ids('scheduled'), {c.pk, d.pk}) + self.assertEqual(self._ids('coordination'), set()) self.assertEqual(self._ids('coordinated'), {b.pk, c.pk, d.pk}) def test_invalid_view_falls_back_to_default(self): @@ -1075,14 +1168,47 @@ class VisitHoldTests(TestCase): counts = self._entezar().context['counts'] self.assertEqual(counts['manual'], len(self._ids('manual'))) self.assertEqual(counts['scheduled'], len(self._ids('scheduled'))) + self.assertEqual(counts['coordination'], len(self._ids('coordination'))) self.assertEqual(counts['coordinated'], len(self._ids('coordinated'))) self.assertEqual(counts['uncoordinated'], len(self._ids('uncoordinated'))) self.assertEqual(counts['total'], len(self._ids('all'))) - # وقتی مشتری‌ای هر دو نوع قرار را دارد، راهنمای بالای صفحه باید - # توضیح بدهد که چرا جمع دو کارت با «با قرار» برابر نیست. - self.assertGreaterEqual(counts['both'], 1) - self.assertIn('پس در هر دو کارت شمرده می‌شوند', self._entezar().content.decode()) + def test_card_number_matches_list_for_every_new_view(self): + """هر سه منبع، فیلتر مستقل خودش را دارد و عدد کارت با فهرست می‌خواند.""" + # فقط هماهنگی (بدون بازدید) — موردِ باگ گزارش‌شده + coord = self._customer('فقط هماهنگی') + Action.objects.create(customer=coord, actionType='hamahangi', status='finish', + actionDateTime=timezone.now() + timedelta(days=8), + finishDateTime=timezone.now() - timedelta(minutes=5)) + # فقط بازدید + sched = self._customer('فقط بازدید') + Action.objects.create(customer=sched, actionType='bazdid', status='waite', + actionDateTime=timezone.now() + timedelta(days=5)) + # هر دو + both = self._customer('بازدید و هماهنگی') + Action.objects.create(customer=both, actionType='bazdid', status='waite', + actionDateTime=timezone.now() + timedelta(days=6)) + Action.objects.create(customer=both, actionType='hamahangi', status='finish', + actionDateTime=timezone.now() + timedelta(days=9), + finishDateTime=timezone.now() - timedelta(minutes=5)) + + counts = self._entezar().context['counts'] + # «هماهنگی» هرگز در نمای «بازدید برنامه‌ریزی‌شده» ظاهر نمی‌شود. + self.assertNotIn(coord.pk, self._ids('scheduled')) + self.assertIn(coord.pk, self._ids('coordination')) + # و برعکس. + self.assertNotIn(sched.pk, self._ids('coordination')) + self.assertIn(sched.pk, self._ids('scheduled')) + # مشتریِ هر دو منبع، در هر دو نما هست. + self.assertIn(both.pk, self._ids('scheduled')) + self.assertIn(both.pk, self._ids('coordination')) + # اما در «با قرار» فقط یک‌بار شمرده می‌شود. + self.assertIn(both.pk, self._ids('coordinated')) + + for key in ('manual', 'scheduled', 'coordination', 'coordinated', + 'uncoordinated', 'total'): + self.assertEqual(counts[key], len(self._ids( + 'all' if key == 'total' else key)), f'عدد کارت {key} با فهرست نمی‌خواند') def test_stats_cards_have_minimal_descriptions(self): """هر کارت آمار باید یک توضیح کوتاه داشته باشد تا معنی‌اش روشن باشد.""" @@ -1090,12 +1216,13 @@ class VisitHoldTests(TestCase): # سرِ نوار آمار + توضیح «عقب‌افتاده» self.assertIn('ez-stats-title', html) self.assertIn('ez-stats-hint', html) - # پنج توضیح، یکی برای هر کارت - self.assertEqual(html.count('class="ez-desc"'), 5) + # پنج توضیح برای کارت‌های بدون‌قرار/با‌قرار/… و ششمی برای هماهنگی + self.assertEqual(html.count('class="ez-desc"'), 6) for phrase in ('هنوز وقت بازدید تعیین نشده', 'وقت بازدیدشان مشخص است', 'تعویق دستی از فرم هماهنگی', - 'اقدام «بازدید» با تاریخ آینده'): + 'اقدام «بازدید» با تاریخ آینده', + 'پیگیری به آینده موکول شده است'): self.assertIn(phrase, html, f'توضیح «{phrase}» روی کارت‌ها نیست') # ---------- اسکنر پیامک هم قرار دستی را می‌بیند ---------- @@ -1338,7 +1465,7 @@ class EntezarLayoutTests(TestCase): def test_column_widths_sum_to_one_hundred(self): widths = [float(w) for w in re.findall( r'', self.html)] - self.assertEqual(len(widths), 8, 'تعداد ستون‌ها باید ۸ باشد') + self.assertEqual(len(widths), 9, 'تعداد ستون‌ها باید ۹ باشد') self.assertAlmostEqual(sum(widths), 100.0, places=1) def test_row_number_is_not_a_column(self):
نام و نام خانوادگی وضعیت قرارقرار هماهنگیقرار دستی بازدید برنامه‌ریزی‌شدههماهنگی آخرین بازدید تأخیر تلفن - {% if appt.manual and appt.scheduled or appt.manual and appt.coordination %} - قرار + بازدید + {% if appt.source_count > 1 %} + {{ appt.source_count }} قرار {% elif appt.manual %} - قرار هماهنگی - {% elif appt.scheduled or appt.coordination %} - بازدید برنامه‌ریزی‌شده + قرار دستی + {% elif appt.scheduled %} + بازدید برنامه + {% elif appt.coordination %} + هماهنگی {% else %} بدون قرار {% endif %} + {% if appt.manual %}{{ appt.manual|toJalali }}{% else %}—{% endif %} - {% if appt.scheduled %}{{ appt.scheduled|toJalali }} - {% elif appt.coordination %}{{ appt.coordination|toJalali }}
در انتظار تماس
- {% else %}—{% endif %} + {% if appt.scheduled %}{{ appt.scheduled|toJalali }}{% else %}—{% endif %} +
+ {% if appt.coordination %}{{ appt.coordination|toJalali }}{% else %}—{% endif %} {% if appt.last_visit %}{{ appt.last_visit|toJalali }}{% else %}بدون بازدید{% endif %} @@ -382,7 +397,7 @@
+ موردی در این نما پیدا نشد.