771 lines
62 KiB
Markdown
771 lines
62 KiB
Markdown
# تحلیل کامل: دو فیلد تاریخِ «اقدام»، صفحهٔ انتظار و پیامکهای بازدید
|
||
|
||
> دامنه: `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) |
|