Files
khatibi/docs/entezar_action_fields_report.md
2026-10-27 11:49:09 +03:30

771 lines
62 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# تحلیل کامل: دو فیلد تاریخِ «اقدام»، صفحهٔ انتظار و پیامک‌های بازدید
> دامنه: `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}`) |
| `install_created` (رویدادی) | بلافاصله پس از ثبت اقدام **نصب** — همان دو مسیر (ایجاد و تغییر نوع در ویرایش) | `notify_install_scheduled` | فقط `actionDateTime` (برای متن `{date}`) |
### ۴.۲ ماتریس اثر (نتیجهٔ شبیه‌سازی، همه با `scan_visit`)
| سناریو | یادآوری `visit` | پیامک `visit_created` |
|---|---|---|
| مشتری عقب‌افتاده، هیچ اقدامی | ✅ ارسال | — |
| «تا تاریخ پیگیری نشود» = +۱۰ روز (با هماهنگی فعال) | ⛔ تا پایان روزِ دهم؛ از فردای آن ✅ | — |
| ستون `visit_hold_until` پر، ولی **بدون** اقدام هماهنگی | ✅ ارسال (ستون بی‌اثر) | — |
| بازدید `waite` با تاریخ +۵ روز | ⛔ (تا پایان آن روز) | — |
| هماهنگی با تاریخ +۵ روز (بدون تعویق) | ⛔ | — |
| هماهنگی «انجام‌شده» با تاریخ گذشته و بدون تعویق | ✅ ارسال | — |
| ثبت اقدام بازدید (هر وضعیتی) | — | ✅ با `{date}` = `actionDateTime` |
| ثبت اقدام نصب (هر وضعیتی) | — | ✅ `install_created`، مستقل از `visit_created` |
| ثبت اقدام هماهنگی | — | ❌ |
| قرار دستی فعال + ثبت بازدید | — | ✅ (فیلد دوم مانع نیست) |
تست‌های متناظر: `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`، تابع `_sync_action_event_sms`) فقط
برای `actionType == bazdid` صدا زده می‌شود، و اصلاً از `next_appointment_map` عبور
نمی‌کند. یعنی اگر هماهنگی بگذارید و بعد بازدیدی ثبت کنید، پیامکِ «بازدید شما برای
تاریخ … ثبت شد» می‌رود حتی اگر «تا تاریخ پیگیری نشود» فعال باشد. همین قاعده برای
`install_created` (اقدام از نوع نصب) هم برقرار است. (اگر رفتار مطلوب چیز دیگری است،
باید در `notify_visit_scheduled`/`notify_install_scheduled` یا در شرط فراخوانی آن‌ها
تغییر کند.)
### ۴.۴ جزئیات مهم `scan_visit`
* `next_appointment_map` = **اجتماع** سه منبع (`manual`، `scheduled`، `coordination`)؛
وجود هر کدام ⇒ `continue` (خط ۲۳۲). پس قرار دستی و تاریخ بازدید و تاریخ هماهنگی هر سه
یادآوری را متوقف می‌کنند — این همان «یک جواب برای صفحه و پیامک» است.
* `dedup_key = visit:{customer_id}:{YYYY-MM موعد}` ⇒ هر دوره حداکثر یک پیامک؛ حذف از صف
فقط همان دوره را کنسل می‌کند.
* خط شروع (`scan_since`) و سقف‌ها می‌توانند ارسال موارد قدیمی را متوقف کنند.
* لنگر چرخه **تماس تلفنی را ریست نمی‌کند**؛ فقط بازدیدِ `finish` (یا نصب) لنگر است.
---
## ۵. تیک «این کار انجام شده است» در اقدام هماهنگی
مسیر تصمیم (`_action_payload_from_request` + `_apply_visit_hold`):
| وضعیت فرم | `Action.status` | `finishDateTime` | `visit_hold_until` | اثر بر فهرست انتظار | اثر بر SMS |
|---|---|---|---|---|---|
| تیک + تاریخ تعویق پر | `finish` | اگر خالی ⇒ `now` | همان تاریخ (پایان روز، فقط رو به جلو) | مشتری تا آن روز پنهان؛ همان روز در `?view=today` برمی‌گردد | یادآوری `visit` تا آن روز قطع |
| تیک + تاریخ تعویق خالی | `finish` | `now` | **+۱۴ روز** | همان | همان |
| بدون تیک + تاریخ تعویق پر | `waite` | `NULL` | همان تاریخ | همان | همان |
| بدون تیک + تاریخ خالی | `waite` | `NULL` | بدون تغییر | مشتری در فهرست می‌ماند | بدون اثر |
نکته‌های ریز:
* در UI، برای هماهنگیِ تیک‌خورده پیش از submit یک **تأیید دو مرحله‌ای** نمایش داده می‌شود
(`holdDoneWarning` / `holdConfirmYes` / `holdConfirmNo` در `customerActionInc.html:609-665`).
اگر کاربر «نه» را بزند، تیک به‌صورت سمت‌کلاینت برداشته می‌شود ⇒ همان سطر سوم/چهارم جدول.
* «انجام شد» هیچ اثر مستقیمی روی «آخرین بازدید» (`last_bazdid_dt`) ندارد؛ هماهنگی هرگز
وارد `last_visit_map` نمی‌شود (`HamahangiIsolationTests`).
* «انجام شد» بدون تاریخ تعویق، **همیشه** ۱۴ روز تعویق می‌سازد؛ اگر کارشناس فقط می‌خواست
تماس را لاگ کند، این پیش‌فرض ناخواسته مشتری را دو هفته پنهان می‌کند.
* در حالت **ویرایش**، `_apply_visit_hold` قرار را پاک نمی‌کند؛ حتی تغییر نوع اقدام از
هماهنگی به بازدید، ستون را دست‌نخورده می‌گذارد (ولی قاعدهٔ «آخرین فعالیت» می‌تواند
آن را بی‌اعتبار کند).
---
## ۶. صفحهٔ `/panel/entezar/` — نمای `?view=today`
### ۶.۱ مسیر محاسبه (گام‌به‌گام)
`views/customers.py:entezar` (۳۷۲-۸۵۳):
1. `now`, `due_cut = visit_due_at(now)` (امروز − ۹ ماه)، `period_months`.
2. `visits = last_visit_map()` — منبع «آخرین بازدید».
3. `sources = appointment_sources_map()` — سه منبع قرارِ **آینده**:
`manual` (ستون تعویق معتبر)، `scheduled` (`bazdid+waite` با تاریخ ≥ آغاز امروز)،
`coordination` (`hamahangi` با تاریخ ≥ همین لحظه).
4. پایهٔ نمای «عقب‌افتاده» (سایر `view`ها): مشتریان `laghv_bazdid=False`، نام‌دار،
`installDate ≤ due_cut`، و سپس `stale_ids` = کسانی که «بازدید معتبر ندارند یا بازدیدشان
≤ `due_cut` است».
5. نمای `today` **جدا** است: `today_followups = followups_on_date(today)` — یعنی
«قرار دستیِ همان روز» + «هماهنگی با تاریخ همان روز»؛ منهای مشتریانی که بازدیدِ
**در انتظار** از آغاز امروز دارند.
6. `_followup_ids`: فیلتر `is_active`، `laghv_bazdid=False`، نام غیرتهی + جست‌وجو؛
مرتب‌سازی بر اساس زودترین موعد.
7. `appointment_rows` برای هر ردیف: ادغام `sources[cid]` با `followups[cid]`، سپس
`source_count` = تعداد کلیدهای موجود از میان `manual/scheduled/coordination`.
8. شمارنده‌ها: `counts['followup_today']` و `counts['followup_overdue']` از همان مسیر
فهرست ⇒ «عدد = تعداد ردیف».
9. عارضهٔ جانبی: ویو در هر GET مقدار `CacheTable('9mahbazdid')` را با
`counts['uncoordinated']` بازنویسی می‌کند (خط ۶۹۹-۷۰۵) — یعنی یک صفحهٔ خواندنی،
نوشتن در دیتابیس دارد.
### ۶.۲ ستون «وضعیت قرار» — معنی برچسب‌ها
قالب `entezar.html:502-515`:
```django
{% if appt.source_count > 1 %} <span>{{ appt.source_count }} قرار</span> ← «۲ قرار»
{% elif appt.manual %} قرار دستی
{% elif appt.scheduled %} بازدید برنامه
{% elif appt.coordination %} هماهنگی
{% else %} بدون قرار
```
* **«N قرار»** = تعداد **منابعِ** قرار، نه تعداد قرارهای واقعی. سه منبع مستقل‌اند و
می‌توانند هم‌زمان وجود داشته باشند.
* «قرار دستی» = ستون `visit_hold_until` (فیلد «تا تاریخ پیگیری نشود»).
* «بازدید برنامه» = اقدام `bazdid` با وضعیت `waite` و تاریخ ≥ آغاز امروز.
* «هماهنگی» = اقدام `hamahangi` (وضعیت `waite` یا `finish`) با تاریخ ≥ همین لحظه.
* ترتیب اولویت برچسب: چندقراره ← دستی ← بازدید ← هماهنگی ← بدون قرار. (ستون
`sort=status` هم همین ترتیب را دارد: 0 بدون، 1 دستی، 2 بازدید، 3 هماهنگی، 4 چندقراره.)
### ۶.۳ شواهد واقعی «۲ قرار»
اعداد زیر روی **کل مشتریان فعالی** که در لحظهٔ ممیزی حداقل یک منبع قرار آینده دارند
محاسبه شده است (نه فقط ردیف‌های یک نما):
| سنجه | مقدار |
|---|---|
| مشتریان با ۱ منبع | ۲٫۲۶۷ |
| مشتریان با ۲ منبع | ۱۶ |
| ترکیب `manual + coordination` | ۱۲ (۱۱ مورد تاریخ‌های **متفاوت**، ۱ مورد هم‌روز) |
| ترکیب `scheduled + coordination` | ۴ |
| بیش از ۲ منبع | ۰ |
نمونه‌ها: `#833` دستی ۲۰۲۶-۱۱-۰۴ / هماهنگی ۲۰۲۶-۱۲-۳۰ — `#2888` هر دو ۲۰۲۶-۱۱-۱۱
(همان یک هماهنگی هم «هماهنگی» شمرده شده هم «قرار دستی»).
**پیامد:** «۲ قرار» همیشه یعنی «دو توافق» نیست. یک هماهنگیِ انجام‌شده که تاریخ تعویق
همان روز را دارد، دو منبع تولید می‌کند و برچسب «۲ قرار» می‌گیرد. اگر هدف نمایش
«تعداد قرارهای واقعی» است، باید منابعِ هم‌منشأ (هماهنگی + قرار دستیِ حاصل از همان اقدام)
یک قرار شمرده شوند.
### ۶.۴ «قرار دستی» — تعریف دقیق
| جنبه | مقدار |
|---|---|
| منبع داده | `Customer.visit_hold_until` (پایانِ روز) |
| نویسنده | فقط اقدام `hamahangi` (فیلد «تا تاریخ پیگیری نشود» / پیش‌فرض ۱۴ روز) |
| شرط اعتبار | یک `hamahangi` فعال + «آخرین فعالیت همان هماهنگی» (`_valid_manual_holds`) |
| شرط نمایش در `?view=today` | `visit_hold_until` داخل بازهٔ همان روز |
| شرط نمایش در `sources` | `visit_hold_until > now` (یعنی قرارِ آینده) |
| اثر | خروج از «تعیین‌تکلیف‌نشده»، ورود به فیلتر `manual`، قطع یادآوری SMS تا آن روز |
عدد واقعی: ۵۴ مشتری ستون پر دارند (۵۱ معتبر)، فقط ۴ ردیف در نمای امروز با منبع
`manual` دیده می‌شوند (۲ فقط-دستی، ۲ دستی+هماهنگی).
---
## ۷. باگ `?view=today&missed=1` — چرا این فهرست پر از مشتریِ بازدیدشده بود؟
> این باگ **رفع شد** (بخش ۹): فهرست از ۱۱۲ ردیف به ۴۲ ردیف رسید و هیچ ردیف
> متناقضی (قرار معتبر + عقب‌افتاده) باقی نماند. متن زیر تحلیل زمان کشف است.
### ۷.۱ تعریف فعلی «عقب‌افتاده» در کد
`visit_policy.overdue_followups` (۵۹۰-۶۴۴):
```python
# آخرین موعد هماهنگی هر مشتری (فقط اقدام hamahangi با waite/finish)
when = as_aware(action_dt) or as_aware(finish_dt) # ← finish هم با actionDateTime
latest[cid] = max(روز when)
overdue = {cid: day for ... if day < today} # ← موعد در گذشته
# تنها حذف‌کننده: بازدیدهای «در انتظار» از زودترین موعد به بعد
pending_visits = _pending_visit_customer_ids(since=آغاز روزِ زودترین موعد)
```
و `_pending_visit_customer_ids` (۴۹۹-۵۱۹) فقط:
```python
actionType='bazdid', status='waite'
```
### ۷.۲ سه ریشهٔ خطا (هم‌راستا با هم)
| # | ریشه | چرا باعث حضور مشتریِ «انجام‌شده» می‌شود |
|---|---|---|
| R1 | **بازدیدِ `finish` هرگز دیده نمی‌شود** | قرار بازدید فقط با `waite` تعریف شده؛ بازدیدی که انجام و بسته شده (`finish`) هیچ اثری در حذف ندارد |
| R2 | **ستون «تا تاریخ پیگیری نشود» نادیده گرفته می‌شود** | `overdue_followups` اصلاً `visit_hold_until` را نمی‌خواند، پس مشتری‌ای که قرارِ آیندهٔ معتبر دارد هم در فهرست «عقب‌افتاده» می‌ماند (و در شمارندهٔ قرمز هم شمرده می‌شود) |
| R3 | **`finish` هم با `actionDateTime` سنجیده می‌شود** | برای هماهنگیِ انجام‌شده، `actionDateTime` «زمان تماس» است نه «قرار بعدی» — همان چیزی که `_future_actions` با `whole_day=False` مستند کرده. نتیجه: هر تماسِ تمام‌شده تا ابد «عقب‌افتاده» می‌ماند تا هماهنگی تازه‌ای ثبت شود |
قاعدهٔ موجود فقط وقتی کار می‌کند که کارشناس **بازدیدِ در انتظار** ثبت کرده باشد؛ یعنی
سناریوی واقعیِ «تماس گرفتم و فردا بازدید می‌کنم» درست عمل می‌کند، اما «تماس گرفتم،
بازدید انجام شد و ثبت شد» هرگز از فهرست خارج نمی‌شود.
### ۷.۳ اعداد واقعی (کپی دیتابیس ۲۰۲۶-۱۰-۲۱)
`?view=today&missed=1` = **۱۱۲ ردیف**:
| سنجه | تعداد | درصد |
|---|---|---|
| حداقل یک بازدیدِ `finish` دارند | ۹۹ | ۸۸٫۴٪ |
| بازدید **پس از ثبت هماهنگی** (createdDT بزرگ‌تر) | ۳۲ | ۲۸٫۶٪ |
| آخرین بازدید **بعد از موعد هماهنگی** | ۲۴ | ۲۱٫۴٪ |
| هیچ بازدیدی ندارند (واقعاً نیازمند پیگیری) | ۱۳ | ۱۱٫۶٪ |
| آخرین هماهنگی `finish` (تماس انجام شده) | ۹۹ | ۸۸٫۴٪ |
| آخرین هماهنگی `waite` | ۱۳ | ۱۱٫۶٪ |
| «قرار دستی» **معتبرِ آینده** دارند و باز هم در فهرست‌اند | ۳۸ | ۳۳٫۹٪ |
| `finish` بدون قرار دستیِ فعال | ۶۱ | ۵۴٫۵٪ |
| بازدیدِ در انتظار (هر تاریخی) دارند | ۰ | ۰٪ |
### ۷.۴ دو نمونهٔ واقعی (خط زمانی کامل)
**#236 — خانم مرضیه جمالی** (`--customer 236`):
```
#77042 hamahangi finish action=2026-10-17 14:31 finish=2026-10-17 15:26 created=2026-10-15
#77176 bazdid finish action=2026-10-18 15:26 finish=2026-10-19 16:12 created=2026-10-17
```
هماهنگی ۱۵ اکتبر ثبت شده، تماس ۱۷ اکتبر انجام شده، **بازدید ۱۸ اکتبر ثبت شده** — ولی
مشتری هنوز در `missed` است، چون R1 و R3 (تاریخِ هماهنگی گذشته است و بازدید `finish`
نادیده گرفته می‌شود).
**#1431 — آقای حمید جمالی:**
```
#63749 hamahangi finish action=2026-10-16 finish=2026-10-17 created=2026-02-09
#77122 bazdid finish action=2026-10-18 created=2026-10-17
#77262 bazdid finish action=2026-10-20 created=2026-10-18
```
دو بازدیدِ انجام‌شده بعد از تماس؛ باز هم در `missed`.
### ۷.۵ چرا «بازدید انجام شده» در ستون «آخرین بازدید» دیده می‌شود ولی چیزی را عوض نمی‌کند؟
ستون «آخرین بازدید» از `last_visit_map` می‌آید (که `bazdid+finish` را می‌بیند)، اما
تصمیم حضور در فهرست از `overdue_followups` می‌آید (که آن را نمی‌بیند). یعنی **دو
تعریف واگرا**؛ دقیقاً همان چیزی که خود `visit_policy` در docstring ابتدایی می‌خواست از
بین ببرد.
### ۷.۶ نمای `today` هم آلوده است (به شکل دیگری)
`?view=today` = **۱۳ ردیف**:
| سنجه | مقدار |
|---|---|
| فقط `coordination` | ۹ |
| فقط `manual` | ۲ |
| `coordination + manual` | ۲ |
| هماهنگی‌های امروز با وضعیت `finish` (تماس انجام‌شده) | ۱ |
| هماهنگی‌های امروز با وضعیت `waite` | ۸ |
| ردیف‌هایی که قرار دستیِ آینده هم دارند (بعداً دوباره می‌آیند) | ۴ |
| ردیف‌هایی که آخرین بازدیدشان در/بعد از همان روز موعد است | ۰ |
> «وضعیت» در دو سطر بالا، وضعیت **آخرین اقدام هماهنگی** همان مشتری است (ممکن است با
> اقدامی که منبع `coordination` امروز را ساخته یکی نباشد).
نتیجه: این نما «موعدِ تماس» را با «موعدِ پیگیری» مخلوط می‌کند؛ تماسِ انجام‌شدهٔ امروز هم
یک ردیف می‌سازد، و اگر همان تماس قرار دستی هم داده باشد، مشتری دو بار (امروز و روزِ
قرار) در فهرست‌های کاری ظاهر می‌شود. همچنین `_pending_visit_customer_ids` فقط
`waite` را حذف می‌کند، پس **بازدیدِ امروزِ انجام‌شده** هم ردیف امروز را حذف نمی‌کند.
### ۷.۷ تناقض آشکار: یک مشتری هم‌زمان «قرار دارد» و «عقب‌افتاده» است
۳۸ مشتری از ۱۱۲ ردیف، در `appointment_sources_map` (و در فیلتر `?view=manual`) قرار
معتبر دارند — یعنی از «تعیین‌تکلیف‌نشده» خارج‌اند — ولی همان لحظه در `missed=1` و در
شمارندهٔ قرمز دیده می‌شوند. مثال: `#3339` قرار دستی ۲۰۲۶-۱۰-۲۲ (امروز) دارد و در
`sources` هم منبع هماهنگی ۲۰۲۶-۱۲-۱۶ دارد؛ یعنی «۲ قرار» گرفته، در حالی که وضعیت
پیگیری‌اش متناقض نمایش داده می‌شود.
---
## ۸. جمع‌بندی ریشه‌ای و پیشنهاد رفع
> وضعیت: **اعمال شد** — فهرست نهایی رفع‌ها، کد تغییرکرده و اثر عددی در بخش ۹.
### ۸.۱ دو تعریف واگرا از «تعیین‌تکلیف شده»
| لایه | معیار فعلی |
|---|---|
| «تعیین‌تکلیف‌نشده» (کارت‌ها/فیلترها) | اجتماع سه منبع قرارِ آینده + اعتبار قرار دستی |
| «عقب‌افتاده» (`missed`) | فقط `hamahangi` + فقط بازدیدِ `waite` |
رفع باگ = هم‌ترازکردن لایهٔ دوم با لایهٔ اول.
### ۸.۲ حداقل تغییرِ ممکن (پیشنهاد، بدون اعمال)
۱. **در `overdue_followups`** بعد از حذف `pending_visits`:
```python
# الف) هر بازدیدِ انجام‌شده که تاریخ مؤثرش از روزِ موعد عقب‌تر است، تکلیف را روشن کرده
for customer_id, action_dt, finish_dt in Action.objects.filter(
actionType=Action.ActionType.BAZDID, status=Action.Status.FINISH,
).values_list('customer_id', 'actionDateTime', 'finishDateTime').iterator():
eff = as_aware(action_dt) if action_dt and as_aware(action_dt) <= now else as_aware(finish_dt)
if eff and eff.date() >= overdue.get(customer_id, today):
overdue.pop(customer_id, None)
# ب) قرار دستیِ معتبر همان چیزی است که کاربر صریحاً گفته «تا این تاریخ پیگیری نکن»
for customer_id in manual_holds_map(now=now):
overdue.pop(customer_id, None)
```
۲. **در `followups_on_date`**: همان قاعدهٔ «بازدید انجام‌شدهٔ بعد از موعد» را هم اعمال
کنید (فیلتر سوم بعد از `pending_visits`)، و اگر مشتری هم `manual` دارد و هم
`coordination` از **همان اقدام**، فقط `manual` را نگه دارید تا ردیف تکراری/«۲ قرار»
کاذب ساخته نشود.
۳. **معنای تاریخ `finish` در هماهنگی**: تصمیم محصولی لازم است؛ پیشنهاد: موعدِ
`finish` را `visit_hold_until` بدانید و `actionDateTime` را فقط برای `waite`
به‌عنوان موعد بشمارید. این تنها راه حذف ۹۹ ردیفِ «تماس انجام‌شده» از فهرست
عقب‌افتاده‌ها بدون از دست دادن قرارهای واقعی است.
۴. **`_valid_manual_holds`**: ابطال قرار را به اقدام‌های «واقعاً جایگزین قرار» محدود کنید
(`bazdid`/`nasb`) نه هر اقدام غیرهماهنگی؛ الان یک سند وصول یا تماس ورودی، قرار را
باطل می‌کند.
۵. **برچسب «N قرار»**: تعداد را بر اساس **تاریخ‌های یکتا** یا **اقدام‌های یکتا** حساب
کنید (نه تعداد کلیدهای منابع)، یا منابع هم‌منشأ را در `appointment_rows` ادغام کنید.
۶. **نوشتن در GET**: بازنویسی `CacheTable('9mahbazdid')` را از ویو به یک دستور/سیگنال
منتقل کنید (کش باید مشتق باشد، نه منبع).
### ۸.۳ ریسک‌ها
* هر تغییر در `overdue_followups` روی شمارندهٔ قرمزِ هدر (`entezarbazdid`/`followup_overdue`)
و هشدارهای صفحه اثر می‌گذارد؛ تست `test_overdue_counter_equals_missed_rows` را حفظ کنید.
* `scan_visit` از `next_appointment_map` می‌خواند، نه از `overdue_followups`؛ پس تغییر
فهرست، پیامک‌ها را جابه‌جا **نمی‌کند** (خوب است).
* تست‌های `MissedViewSimulationTests` رفتار فعلی را قفل کرده‌اند؛ بعد از رفع، باید
`assertIn` به `assertNotIn` تبدیل شود.
---
## ۹. باگ‌های شناسایی‌شده و رفع‌شده
> تاریخ رفع: ۲۰۲۶-۱۰-۲۲ · بدون تغییر در قرارداد داده‌ها (بدون migration) ·
> ۲۶۹ + ۱۹۹ تست قبلی سبز ماند + ۴۶ تست شبیه‌سازی/رگرسیون.
### ۹.۱ چرخهٔ کسب‌وکار (مبنا همهٔ تصمیم‌ها)
```
نصب دستگاه ──► ۹ ماه ──► «موعد بازدید» (کارت عقب‌افتاده + پیامک visit)
│
▼
تماس کارشناس (hamahangi)
├── انجام نشده (waite) ──► موعد تماس = actionDateTime ──► نمای «امروز»
└── انجام شد (finish) ───► «تا تاریخ پیگیری نشود» ──► پنهان تا آن روز + قطع SMS
│
▼
بازدید انجام شد (bazdid + finish)
──► لنگر چرخه از نو (۹ ماه) + پیامک visit_created
```
اصل حاکم بر همهٔ رفع‌ها: **قرار/بازدیدِ انجام‌شده باید فهرست کار را ببندد، ولی هیچ
مشتریِ بدون قرار نباید بی‌صدا از فهرست کار ناپدید شود.**
### ۹.۲ جدول باگ‌ها
| # | نوع | باگ | ریشه | رفع |
|---|---|---|---|---|
| B1 | کسب‌وکار | فهرست `missed=1` پر از مشتریانی که بازدیدشان انجام شده بود (۹۹ از ۱۱۲) | `overdue_followups` فقط بازدیدِ `waite` را حذف می‌کرد | بازدیدِ `finish` با تاریخ مؤثر ≥ موعد ⇒ حذف از فهرست |
| B2 | کسب‌وکار | ۳۸ ردیف هم‌زمان «قرار معتبر آینده» و «عقب‌افتاده» بودند | ستون `visit_hold_until` در `overdue_followups` خوانده نمی‌شد | قرار دستیِ معتبر ⇒ حذف از فهرست |
| B3 | کسب‌وکار | نمای «امروز» بعد از بازدیدِ همان روز، ردیف را نگه می‌داشت | `followups_on_date` فقط `waite` را حذف می‌کرد | بازدیدِ انجام‌شده از همان روز به بعد ⇒ حذف ردیف (فقط امروز/آینده) |
| B4 | کسب‌وکار | یک سند وصول یا تماس ورودی، «قرار دستی» را بی‌صدا باطل می‌کرد | `_valid_manual_holds` هر اقدام غیرهماهنگی را «جایگزین قرار» می‌گرفت | فقط `bazdid`/`nasb` قرار را باطل می‌کنند (`_SUPERSEDING_TYPES`) |
| B5 | کسب‌وکار | «۲ قرار» برای یک توافق (هماهنگی + تعویقِ همان روز) | `source_count` تعداد منابع بود، نه تعداد قرار | شمارش روزهای یکتا (و همان قاعده در `sort=status`) |
| B6 | فنی | مرز «وظایف امروز» با رشتهٔ naive ساخته می‌شد (وابسته به تایم‌زون/DST) | `strftime('%Y-%m-%d 00:00:00')` در `ActionListToday`/`ActionListUserToday` | `_local_day_range()` صریح و aware با پوشش DST |
| B7 | فنی | هر GET صفحهٔ انتظار، `CacheTable` را می‌نوشت (حتی بدون تغییر) | `save()` بی‌قید در ویو | فقط در صورت تغییر + `update_fields` |
| B8 | فنی (تست) | تست `test_overdue_column_hidden_only_in_followup_view` به ساعت اجرا وابسته بود | هماهنگی «امروز ساعت ۱۲» می‌توانست «آینده» شمرده شود | تست با مشتری بدون قرار، مستقل از ساعت |
| B9 | کسب‌وکار | اقدام **«نصب»** ردیف را از فهرست‌های کار (`?view=today` و `?view=today&missed=1`) حذف نمی‌کرد؛ فقط «بازدید» حذف می‌کرد | `followups_on_date`/`overdue_followups` در دو مرحلهٔ «قرار در انتظار» و «انجام‌شده» فقط `bazdid` را می‌دیدند (`_pending_visit_customer_ids` و `last_visit_map`) | آن دو مرحله به `_pending_appointment_ids` (با `_CYCLE_RESET_TYPES` = `bazdid`/`nasb`) و `cycle_anchor_map` منتقل شدند؛ `last_visit_map` دست‌نخورده ماند |
| B10 | رویداد | تغییر نوع یک اقدام از «هماهنگی» به «بازدید» در فرم **ویرایش**، پیامک `visit_created` نمی‌فرستاد | تریگر `notify_visit_scheduled` فقط در `customerNewAction` صدا زده می‌شد (یک فراخوان در کل کد) | در `customerEditAction` گذار نوع بررسی می‌شود: غیربازدید → بازدید ⇒ تریگر؛ بازدید → غیربازدید ⇒ `cancel_pending_sms_for_action(action_id=…)`. تکرار با `dedup_key=visit_created:{action.pk}` مهار می‌شود |
| F1 | قابلیت | نوع پیامک «ثبت نصب» (اقدام از نوع نصب) وجود نداشت | فرم‌های اقدام از نوع نصب هیچ تریگر پیامکی نداشتند | `SMSType.INSTALL_CREATED` + `notify_install_scheduled` + قالب قابل ویرایش در `/panel/sms/templates` + تریگر در هر دو ویوی ایجاد/ویرایش (مهاجرت ۰۰۸۱) |
> **F1 — قیدهای ایمنی مهاجرت ۰۰۸۱:** مهاجرت فقط `choices` را گسترش می‌دهد
> (`AlterField` = متادیتا، بدون تغییر داده) و **یک** ردیف تازه با
> `get_or_create` می‌سازد. هیچ `update`/`save` روی قالب‌های موجود ندارد؛ اگر قالب
> `install_created` از قبل باشد هم دست نمی‌خورد. قالب تازه:
> `is_enabled=True` (در پنل دیده شود) و **`auto_send=False`** ⇒ پیامک‌ها در صف
> «در انتظار تأیید» می‌مانند تا مدیر خودش تأیید کند. تست‌های نگهبان:
> `InstallTemplateMigrationTests` و `test_install_template_is_seeded_with_auto_send_off`
> در `tests/test_sms.py`.
> **B9 — جزئیات (۲۰۲۶-۱۰-۲۷):** «نصب» از قبل در `_SUPERSEDING_TYPES` (ابطال قرار
> دستی) و `_CYCLE_RESET_TYPES` (ریست چرخهٔ ۹ ماهه در اسکنر پیامک) بود، ولی فهرست
> کار روزانه آن را نمی‌دید. حالا:
>
> * `nasb + waite` با تاریخ از آغاز روز مورد نظر به بعد ⇒ ردیف پنهان می‌شود
> (همان قاعدهٔ `bazdid + waite`؛ «قرار امروز» تا پایان امروز معتبر است).
> نصبِ در انتظارِ **قدیمی‌تر** از آن روز، قرار فعال نیست و ردیف را باز نمی‌گذارد.
> * `nasb + finish` با تاریخ مؤثر از موعد به بعد ⇒ ردیف بسته می‌شود (همان قاعدهٔ
> `bazdid + finish`).
> * `last_visit_map` (تاریخ آخرین **بازدید**، ستون جدول، `last_bazdid_dt` و پیامک)
> عمداً تغییر نکرد؛ «نصب» فقط لنگر چرخه است، نه بازدید.
### ۹.۳ اثر روی دادهٔ واقعی (همان نسخهٔ پشتیبان)
| سنجه | قبل | بعد |
|---|---|---|
| ردیف‌های `?view=today&missed=1` | **۱۱۲** | **۴۲** |
| ردیف با قرار دستیِ معتبر و هم‌زمان در فهرست | ۳۸ | **۰** |
| ردیف با بازدیدِ بعد از موعد (بسته‌شده) | ۶۲ (۳۲ + ۲۴ + …) | **۰** |
| ردیف با بازدیدِ قدیمی‌تر از موعد (واقعاً نیازمند) | ۳۱ | ۳۱ |
| ردیف بدون هیچ بازدید | ۱۳ | ۱۳ |
نمونه‌های گزارش‌شده: `#236` و `#1431` دیگر در فهرست نیستند (بازدیدشان بعد از موعد
هماهنگی ثبت شده بود).
### ۹.۴ چرا این رفع‌ها چرخه را نمی‌شکنند
* `next_appointment_map` (منبع تصمیم اسکنر پیامک) دست نخورده است ⇒ **هیچ پیامکی
جابه‌جا نشد**؛ فقط نمایش فهرست کار درست شد.
* هیچ فیلد/معنای داده‌ای تغییر نکرد؛ `visit_hold_until` همچنان مشتق و بی‌اعتبار
به‌تنهایی است.
* هر رفع، «حذف از فهرست کار» است نه «حذف مشتری»: مشتریِ بدون قرار همچنان در
`?view=uncoordinated` (تعیین‌تکلیف‌نشده) دیده می‌شود.
* قاعدهٔ «عدد هشدار = تعداد ردیف فهرست» حفظ شد (شمارنده از همان تابع می‌آید).
* `followups_on_date` برای **روزهای گذشته** رفتار قبلی را نگه می‌دارد تا مرور
تقویم، سندِ کاریِ همان روز را بازنویسی نکند.
### ۹.۵ باقی‌مانده (تصمیم محصولی، عمداً تغییر نکرد)
* موعدِ هماهنگیِ `finish` همچنان «زمان تماس» است. اگر تعویق منقضی شود و بازدیدی
ثبت نشود، مشتری دوباره «نیازمند پیگیری» می‌شود. این عمدی است: «ناپدید شدن
بی‌صدای مشتریِ بی‌قرار» خطرناک‌تر از «یک ردیف اضافه» است. اگر کسب‌وکار تصمیم
بگیرد تماسِ انجام‌شده چرخه را ۹ ماه عقب بیندازد، تغییر فقط افزودن `hamahangi`
به `_CYCLE_RESET_TYPES` است — با اثر جانبی روی پیامک‌ها.
* `Customer.visit_hold_until` همچنان ستون مشتق است (بدون migration)؛ تنها
نویسنده‌اش باید `set_visit_hold` بماند.
### ۹.۶ بازخورد شفاف + تأیید کاربر برای «عقب‌بردن» تاریخ قرار
> پیاده‌سازی‌شده · بدون migration · منطق تصمیم‌گیری عوض نشده (قاعدهٔ «فقط رو به جلو» پابرجاست).
پیش از این، اگر تاریخ «تا تاریخ پیگیری نشود» از قرار فعلی عقب‌تر بود،
`set_visit_hold` مقدار `False` برمی‌گرداند و لایهٔ فراخوان آن را دور می‌ریخت:
نه نوشتنی، نه پیامی، نه نشانه‌ای. حالا زنجیره کامل و شفاف است:
| لایه | تغییر |
|---|---|
| `visit_policy.set_visit_hold(..., force=False)` | پارامتر تأیید صریح؛ پیش‌فرض همان قاعدهٔ «فقط رو به جلو» |
| `views/actions._apply_visit_hold(...)` | خروجی ساخت‌یافته: `{'ok', 'reason', 'current', 'target', 'force_allowed'}` — `reason='forward_only'` یعنی نوشته نشد |
| `views/actions._report_hold_result(...)` | `messages.warning` کوتاه: «تاریخ قرار ذخیره نشد؛ از قرار فعلی این مشتری (تاریخ) عقب‌تر است.» + مسیر انجام (تیک/ویرایش) |
| `customerNewAction` / `customerEditAction` | مقدار `force_hold` فرم و گزارش نتیجه به کاربر |
| `customerActionInc.html` | اعلان `holdBackwardNotice` + `force_hold` + تیک «تاریخ قرار را عقب ببر» (فقط در ویرایش) |
| `customerBaseNew.html` | پیام `warning` با رنگ هشدار (کهربایی) نمایش داده می‌شود |
### تجربهٔ کاربر
1. تاریخ عقب‌تر را انتخاب می‌کند؛ همان لحظه (نه فقط هنگام ثبت) اعلان کوتاه باز می‌شود:
«این تاریخ از قرار فعلی عقب‌تر است» / «قرار فعلی: ۱۴۰۵/۰۸/۰۲».
2. **در ویرایش**: تیک «تاریخ قرار را عقب ببر» ⇒ `force_hold=1` و ذخیره می‌شود.
بدون تیک، ثبت بلاک می‌شود و فوکوس روی همان اعلان می‌رود.
3. **در ایجاد وظیفهٔ تازه**: تیک وجود ندارد؛ به‌جای آن یک خط راهنما:
«این تاریخ ذخیره نمی‌شود. برای تغییر قرار، وظیفهٔ هماهنگی فعلی را ویرایش کنید.»
دلیل: در ایجاد، کاربر قصد اصلاح قرار موجود را ندارد و اجازه دادن می‌توانست
ناخواسته قولِ ثبت‌شدهٔ قبلی (شاید کارِ کارشناس دیگر) را کوتاه کند. خودِ وظیفه
ثبت می‌شود؛ فقط تاریخ قرار عقب نمی‌رود.
4. اگر جاوااسکریپت اجرا نشود یا کاربر مستقیم POST بزند، **سرور** هم مستقل بررسی
می‌کند: نه می‌نویسد، نه ساکت می‌ماند — پیام هشدار روی صفحهٔ اقدام نمایش داده می‌شود
(برای ایجاد، پیام «همان وظیفه را ویرایش کنید» را نشان می‌دهد).
5. همان روز ⇒ تغییر واقعی لازم نیست؛ هیچ پیامی هم ساخته نمی‌شود.
### تست‌ها (`HoldBackwardConfirmTests` — ۸ مورد)
| تست | چه چیزی را تضمین می‌کند |
|---|---|
| `test_earlier_date_is_not_saved_and_user_is_told` | نوشته نمی‌شود + پیام شامل «ذخیره نشد»، قرار فعلی و مسیر تأیید؛ پیام در HTML صفحه هم دیده می‌شود |
| `test_user_confirmation_writes_the_earlier_date` | با `force_hold` تاریخ عقب‌تر ذخیره می‌شود |
| `test_forward_date_needs_no_confirmation` | تاریخ جلوتر مثل قبل بدون پرسش ذخیره می‌شود |
| `test_same_day_is_silent` | همان روز ⇒ بدون پیام |
| `test_create_does_not_move_the_hold_backward` | در ایجاد، حتی با `force_hold=1` ستون عقب نمی‌رود، ولی خودِ وظیفه ثبت می‌شود + پیام «ویرایش کنید» |
| `test_apply_visit_hold_reports_reason` | قرارداد تابع: `reason='forward_only'` و `force_allowed=True` در ویرایش |
| `test_apply_visit_hold_ignores_force_on_create` | `force_hold` در ایجاد نادیده گرفته می‌شود |
| `test_modal_exposes_confirmation_controls` | قالب شامل `data-customer-hold`، `force_hold`، `hbForceRow`، `hbCreateHint` است |
علاوه بر آن، منطق جاوااسکریپت با اجرای همان کدِ قالب (نه کپی) در Node آزموده شد:
«عقب‌تر ⇒ هشدار»، «جلوتر/همان روز/بدون قرار/نوع غیرهماهنگی ⇒ بی‌هشدار».
---
## ۱۰. سناریوی «تعویق پیش از تاریخ کار» (۱۴۰۵/۰۸/۰۳ در برابر ۱۴۰۵/۰۸/۰۵)
> بازتولید: `scripts/coordination_hold_order_probe.py --customer 5124`
> قفل رفتاری: `tests/test_entezar_action_simulation.py::HoldBeforeWorkDateSimulationTests`
### ۱۰.۱ چه اتفاقی می‌افتد
| ورودی | مقدار ذخیره‌شده |
|---|---|
| نوع = هماهنگی · «تاریخ و ساعتی که کار باید انجام شود» = ۱۴۰۵/۰۸/۰۵ | `Action.actionDateTime` = ۲۰۲۶-۱۰-۲۷ |
| «تا تاریخ پیگیری نشود» = ۱۴۰۵/۰۸/۰۳ | `Customer.visit_hold_until` = ۲۰۲۶-۱۰-۲۵ ۲۳:۵۹:۵۹ |
هر دو تاریخ در آینده‌اند، پس `normalize_hold_until` قبول می‌کند:
**هیچ اعتبارسنجی‌ای «تعویقِ زودتر از تاریخ کار» را رد نمی‌کند** (نه در فرم، نه در سرور).
نتیجه: دو تاریخ متفاوت برای یک مشتری ⇒ منبع `manual` (۱۰-۲۵) + منبع `coordination` (۱۰-۲۷).
### ۱۰.۲ گاه‌شمار رفتار (شبیه‌سازی‌شده، هم با تیک و هم بدون تیک)
| روز | منابع قرار | برچسب | «نیازمند پیگیری امروز» | «عقب‌افتاده» | یادآوری SMS |
|---|---|---|---|---|---|
| ۱۰-۲۲ تا ۱۰-۲۴ | manual + coordination | **۲ قرار** | خیر | خیر | ارسال نمی‌شود |
| ۱۰-۲۵ (روزِ تعویق) | manual + coordination | ۲ قرار | **بله** ← ردیف کاری اضافه | خیر | ارسال نمی‌شود |
| ۱۰-۲۶ | coordination | ۱ قرار | خیر | خیر | ارسال نمی‌شود |
| ۱۰-۲۷ (تاریخ کار) | coordination | ۱ قرار | **بله** (روز قرار) | خیر | ارسال نمی‌شود |
| ۱۰-۲۸ به بعد | — | بدون قرار | خیر | **بله** | (موعد چرخه) |
مقایسه با حالت سالم (تعویق ۱۱-۰۱ ≥ تاریخ کار ۱۰-۲۷): از ۱۰-۲۸ به بعد
`manual` باقی می‌ماند، «۱ قرار» می‌شود و مشتری «عقب‌افتاده» نمی‌شود.
### ۱۰.۳ اثر تیک «انجام شد»
| مورد | با تیک | بدون تیک |
|---|---|---|
| `status` | `finish` | `waite` |
| `finishDateTime` | اگر خالی ⇒ **همین حالا** (نه ۱۰-۲۷) | `NULL` |
| `visit_hold_until` | همان ۱۰-۲۵ | همان ۱۰-۲۵ |
| پنهان‌سازی/«۲ قرار»/SMS | **یکسان** | **یکسان** |
| «لیست وظایف» و شمارندهٔ داشبورد (`status='waite'`) | خارج می‌شود | می‌ماند (وظیفهٔ ۱۰-۲۷) |
| صف «نظرسنجی» (`finish` و `nazarDT` خالی) | **وارد می‌شود** | وارد نمی‌شود |
| تشخیص «قرار دستی» | معتبر (همهٔ شرط‌ها) | معتبر |
یعنی تیک روی «قرار» اثری ندارد؛ فقط «وظیفه بسته شد» را اعلام می‌کند و در عوض
مشتری را به صف نظرسنجی می‌برد.
### ۱۰.۴ پیامدهای کسب‌وکاری این ورودی
1. **ردیف کاری تکراری**: در ۱۰-۲۵ (روز تعویق) مشتری در «نیازمند پیگیری» ظاهر می‌شود،
در حالی که قرارِ توافق‌شده ۱۰-۲۷ است ⇒ تماسِ بی‌مورد.
2. **برچسب گمراه‌کننده**: «۲ قرار» نشان می‌دهد دو توافق وجود دارد، ولی در واقع یک
توافق است با تعویقی که زودتر از موعدِ کار تمام می‌شود.
3. **سقوطِ یک‌روزه به «عقب‌افتاده»**: فردای تاریخ کار، تعویق قبلاً منقضی شده، پس
«بدون قرار» و «عقب‌افتاده» می‌شود.
4. **بدون ارسال پیامک در فاصلهٔ ۱۰-۲۵ تا ۱۰-۲۷**: چون `next_appointment_map` (که اسکنر
می‌خواند) هنوز منبع `coordination` را دارد؛ صفحه و پیامک واگرا نمی‌شوند.
### ۱۰.۵ توصیه
* برای یک توافق، **تاریخ تعویق = تاریخ کار** (یا دورتر) بگذارید؛ آن‌وقت «۱ قرار» می‌شود
و ردیف تکراری ساخته نمی‌شود.
* اگر می‌خواهید سیستم جلوی این ورودی را بگیرد، حداقل تغییر: در
`normalize_hold_until`/ویو، اگر `hold < actionDateTime` بود، **هشدار** بدهید
(نه خطا) — چون در برخی سناریوها («تعویق تا این تاریخ، بعدش خودم تماس می‌گیرم»)
ممکن است عمدی باشد.
---
## ۱۱. بازتولید و اعتبارسنجی
### ۱۱.۱ ممیزی روی دیتابیس واقعی (فقط خواندن)
```bash
.venv/Scripts/python.exe scripts/entezar_action_audit.py
.venv/Scripts/python.exe scripts/entezar_action_audit.py --samples 10
.venv/Scripts/python.exe scripts/entezar_action_audit.py --customer 236
.venv/Scripts/python.exe scripts/entezar_action_audit.py --db data9/<backup>.sqlite3
```
اسکریپت ابتدا کل فایل sqlite (به‌همراه `-wal`/`-shm`) را در پوشهٔ موقت کپی می‌کند و
همهٔ کوئری‌ها/توابع `visit_policy` را روی همان کپی اجرا می‌کند؛ دیتابیس اصلی دست‌نخورده
می‌ماند. خروجی شامل پنج بخش است (missed، today، برچسب‌ها، ستون قرار، هماهنگی‌ها) و با
`--customer` خط زمانی کامل یک مشتری.
### ۱۱.۲ شبیه‌سازی‌ها
```bash
.venv/Scripts/python.exe manage.py test tests.test_entezar_action_simulation -v 2
```
نتیجهٔ اجرا: **۵۵ تست، OK** (Django 3.2.25، SQLite حافظه‌ای). پوشش:
| کلاس | تعداد | چه چیزی را ثابت می‌کند |
|---|---|---|
| `ActionFormSimulationTests` | ۹ | نگاشت دو فیلد، پیش‌فرض ۱۴ روز، رد تاریخ گذشته/بیش از ۶ ماه، «فقط رو به جلو»، بی‌اثری روی بازدید |
| `EntezarDecisionSimulationTests` | ۸ | «۲ قرار» فقط برای دو روز متفاوت، «قرار دستی»، ابطال با بازدید، روزِ پیگیری، خروج با بازدید آینده |
| `AppointmentLifetimeSimulationTests` | ۴ | سند وصول/تماس قرار را باطل نمی‌کند؛ بازدید/نصب باطل می‌کند (B4) |
| `FollowupDaySimulationTests` | ۳ | بازدیدِ امروز ردیف امروز را می‌بندد؛ بازدید دیروز نه؛ مرور روز گذشته دست‌نخورده (B3) |
| `TaskListBoundaryTests` | ۲ | مرز ۰۰:۳۰/۲۳:۳۰ به وقت محلی؛ وظیفهٔ انجام‌شده در «امروز» نمی‌آید (B6) |
| `CacheTableWriteTests` | ۱ | عدد کش داشبورد = شمارندهٔ «تعیین‌تکلیف‌نشده» (B7) |
| `MissedViewSimulationTests` | ۹ | رفع B1/B2: بازدید `finish` و قرار دستی معتبر از فهرست خارج می‌کنند؛ قرارِ مرده نه |
| `SmsSimulationTests` | ۱۱ | قطع/بازگشت یادآوری دوره‌ای، بی‌اثری ستون تنها، `visit_created` با تاریخ، پاک‌شدن ستون با ثبت بازدید |
| `HoldBeforeWorkDateSimulationTests` | ۹ | سناریوی «تعویق پیش از تاریخ کار»: پذیرش، «۲ قرار»، ردیف تکراری، سکوت SMS، سقوط به missed، تفاوت تیک/بدون‌تیک |
> سناریوی ۱۴۰۵/۰۸/۰۳ در برابر ۱۴۰۵/۰۸/۰۵ در بخش ۱۰ به‌تفصیل آمده و با
> `scripts/coordination_hold_order_probe.py` قابل بازپخش است.
### ۱۱.۳ محدودیت‌ها / فرض‌ها
* همهٔ اعداد از **یک نسخهٔ پشتیبان** (۲۰۲۶-۱۰-۲۱) است؛ با دادهٔ فردا تغییر می‌کنند.
* ممیزی «تاریخ مؤثر» را با قاعدهٔ `effective_visit_dt` بازسازی می‌کند؛ اگر رکوردی
`actionDateTime` آینده داشته باشد، `finishDateTime` مبنا می‌شود.
* «بازدید انجام‌شده پس از هماهنگی» بر اساس `createdDT` (زمان ثبت) سنجیده شده، نه تاریخ
واقعی بازدید؛ چون `createdDT` تنها ستونی است که ترتیب ثبت را تضمین می‌کند.
* هیچ‌کدام از تست‌ها به «امروزِ واقعی» وابسته نیستند مگر از طریق `timezone.now()` در
لحظهٔ اجرا؛ برای همین در هر تاریخ دیگری هم باید سبز بمانند (تاریخ‌ها نسبی‌اند).
### ۱۱.۴ فایل‌های مرتبط
| فایل | نقش |
|---|---|
| `admin_panel/visit_policy.py` | تنها منبع حقیقت قرار/بازدید (`last_visit_map`، `appointment_sources_map`، `followups_on_date`، `overdue_followups`، `manual_holds_map`) |
| `admin_panel/views/actions.py` | ایجاد/ویرایش/حذف اقدام، `_apply_visit_hold`، لیست وظایف |
| `admin_panel/views/customers.py` | ویوی `entezar` و ساخت `appointment_rows` |
| `admin_panel/sms/scanner.py` | `scan_visit` (یادآوری دوره‌ای) |
| `admin_panel/sms/triggers.py` | `notify_visit_scheduled` (پیامک ثبت بازدید) |
| `admin_panel/templates/admin_panel/entezar.html` | برچسب‌های وضعیت و ستون‌های قرار |
| `admin_panel/templates/admin_panel/customerActionInc.html` | فرم و JS دو فیلد + تأیید «هماهنگی انجام شد» |
| `docs/visit_hold_entezar.md` | طراحیِ موردنظر (سند قبلی) — بخش‌هایی از آن در کد فعلی رعایت نشده (R2/R3) |