Files
khatibi/docs/entezar_action_fields_report.md
T
2026-10-22 14:01:08 +03:30

58 KiB
Raw Blame History

تحلیل کامل: دو فیلد تاریخِ «اقدام»، صفحهٔ انتظار و پیامک‌های بازدید

دامنه: 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:

{% 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 (۵۹۰-۶۴۴):

# آخرین موعد هماهنگی هر مشتری (فقط اقدام 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 (۴۹۹-۵۱۹) فقط:

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:

# الف) هر بازدیدِ انجام‌شده که تاریخ مؤثرش از روزِ موعد عقب‌تر است، تکلیف را روشن کرده
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 بود، هشدار بدهید (نه خطا) — چون در برخی سناریوها («تعویق تا این تاریخ، بعدش خودم تماس می‌گیرم») ممکن است عمدی باشد.

۱۱. بازتولید و اعتبارسنجی

۱۱.۱ ممیزی روی دیتابیس واقعی (فقط خواندن)

.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 خط زمانی کامل یک مشتری.

۱۱.۲ شبیه‌سازی‌ها

.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)