"""شبیه‌سازیِ رفتار «اقدام / هماهنگی / قرار» روی دادهٔ ساختگی. این فایل، کدِ شبیه‌سازیِ سند ``docs/entezar_action_fields_report.md`` است. هر کلاس یک سناریوی واقعی را با همان توابع تولیدی (نه بازنویسی) اجرا می‌کند تا رفتار سیستم قابل بازتولید و قابل حسابرسی بماند: * ``ActionFormSimulationTests`` — معنی و اثر دو فیلد فرم اقدام: «تاریخ و ساعتی که کار باید انجام شود» (``dateTimeAnjam``) و «تا تاریخ پیگیری نشود» (``visitHoldUntil``). * ``EntezarDecisionSimulationTests`` — اثر آن‌ها بر صفحهٔ انتظار، برچسب «N قرار» و «قرار دستی». * ``MissedViewSimulationTests`` — بازتولیدِ باگ گزارش‌شده در ``?view=today&missed=1`` (حضور مشتریِ بازدیدشده). این تست‌ها **رفتار فعلی** را قفل می‌کنند تا وقتی باگ رفع شد، آگاهانه به‌روزرسانی شوند. * ``SmsSimulationTests`` — اثر همان فیلدها بر پیامک‌های بازدید (``visit`` دوره‌ای و ``visit_created`` رویدادی). اجرا:: .venv/Scripts/python.exe manage.py test tests.test_entezar_action_simulation .venv/Scripts/python.exe manage.py test tests.test_entezar_action_simulation -v 2 """ import datetime from datetime import timedelta from django.test import TestCase from django.utils import timezone from persiantools.jdatetime import JalaliDate from admin_panel.models import (Action, Customer, PhoneNumber, SMSProviderConfig, SMSQueue, SMSTemplate, SMSType) from admin_panel.sms.render import format_jalali from admin_panel.sms.scanner import scan_visit from admin_panel.visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED, appointment_sources_map, as_aware, end_of_local_day, followups_on_date, manual_holds_map, next_appointment_map, overdue_followups) from .factories import make_customer, make_user NOW = timezone.now().replace(microsecond=0) def at(days=0, hours=0): """یک لحظهٔ aware نسبت به «همین حالا».""" return NOW + timedelta(days=days, hours=hours) def ms(moment): """لحظه → timestamp میلی‌ثانیه‌ای، همان چیزی که پیکر تاریخ در فرم می‌فرستد.""" return str(int(moment.timestamp() * 1000)) def jalali(moment): return JalaliDate(moment).strftime('%Y/%m/%d') def make_action(customer, action_type, status, *, action_dt=None, finish_dt=None, **extra): """ثبت اقدام با کمترین فیلد ممکن (همان کاری که ویو انجام می‌دهد).""" return Action.objects.create( customer=customer, actionType=action_type, status=status, actionDateTime=action_dt, finishDateTime=finish_dt, desc='شبیه‌سازی', result='', **extra ) class SimulationBase(TestCase): """مشتریِ «عقب‌افتاده» + کاربر واردشده، مشترک همهٔ سناریوها.""" INSTALL_DAYS = 400 def setUp(self): self.user = make_user('simExpert', superuser=True) self.client.force_login(self.user) self.customer = make_customer('مشتری شبیه‌سازی') PhoneNumber.objects.create( customer=self.customer, phone_number='09120000099', ) Customer.objects.filter(pk=self.customer.pk).update( installDate=timezone.localdate() - timedelta(days=self.INSTALL_DAYS), ) self.customer.refresh_from_db() def post_action(self, **payload): """ارسال فرم ایجاد اقدام؛ کلیدهای ناشناخته به payload اضافه می‌شوند.""" data = {'userID': self.user.pk, 'desc': 'شبیه‌سازی'} data.update(payload) return self.client.post( '/panel/customer/%d/action/new' % self.customer.pk, data, ) def last_action(self): return Action.objects.filter(customer=self.customer).order_by('-id').first() def stale_ids(self, view='all'): response = self.client.get('/panel/entezar/?view=' + view) self.assertEqual(response.status_code, 200) return {c.id for c in response.context['customers']}, response class ActionFormSimulationTests(SimulationBase): """دو فیلد تاریخِ فرم: کجا ذخیره می‌شوند و چه محدودیتی دارند.""" def test_action_datetime_is_taken_from_date_time_anjam(self): """«تاریخ و ساعتی که کار باید انجام شود» ⇒ ``Action.actionDateTime``.""" when = at(5) response = self.post_action(actionType='bazdid', dateTimeAnjam=ms(when)) self.assertEqual(response.status_code, 302) action = self.last_action() self.assertIsNotNone(action, msg='اقدام باید ثبت شده باشد') self.assertEqual(action.actionDateTime, when) self.assertEqual(action.status, Action.Status.WAITE) self.assertIsNone(action.finishDateTime) def test_hold_until_is_saved_on_customer_end_of_day(self): """«تا تاریخ پیگیری نشود» ⇒ ``Customer.visit_hold_until`` پایانِ همان روز.""" target = at(10) response = self.post_action( actionType='hamahangi', dateTimeAnjam=ms(NOW), visitHoldUntil=ms(target), is_done='on', ) self.assertEqual(response.status_code, 302) self.customer.refresh_from_db() self.assertEqual(self.customer.visit_hold_until, end_of_local_day(target)) self.assertIsNotNone(self.customer.visit_hold_set_at) # «تاریخ انجام» دست‌نخورده در اقدام می‌ماند (دو مفهوم، دو فیلد). self.assertEqual(self.last_action().actionDateTime, NOW) def test_hold_until_ignored_for_visit_actions(self): """برای اقدام «بازدید» فیلد تعویق هیچ اثری ندارد (حتی اگر ارسال شود).""" self.post_action( actionType='bazdid', dateTimeAnjam=ms(NOW), visitHoldUntil=ms(at(10)), is_done='on', ) self.customer.refresh_from_db() self.assertIsNone(self.customer.visit_hold_until) def test_done_coordination_without_hold_defaults_to_14_days(self): """تیک «انجام شد» + فیلد خالی ⇒ پیش‌فرض ۱۴ روز.""" self.post_action(actionType='hamahangi', dateTimeAnjam=ms(NOW), is_done='on') self.customer.refresh_from_db() self.assertEqual( self.customer.visit_hold_until, end_of_local_day(NOW + timedelta(days=14)), ) self.assertEqual(self.last_action().status, Action.Status.FINISH) # تاریخ اتمام خالی ⇒ زمان ثبت (نه NULL). self.assertIsNotNone(self.last_action().finishDateTime) def test_open_coordination_without_hold_sets_no_hold(self): """بدون تیک و بدون تاریخ ⇒ هیچ تعویقی ثبت نمی‌شود؛ مشتری در فهرست می‌ماند.""" self.post_action(actionType='hamahangi', dateTimeAnjam=ms(NOW)) self.customer.refresh_from_db() self.assertIsNone(self.customer.visit_hold_until) self.assertEqual(self.last_action().status, Action.Status.WAITE) ids, _ = self.stale_ids('uncoordinated') self.assertIn(self.customer.pk, ids) def test_open_coordination_with_hold_sets_hold_and_stays_open(self): """هماهنگیِ «انجام‌نشده» با تاریخ تعویق: قرار ثبت می‌شود ولی وضعیت waite است.""" target = at(7) self.post_action( actionType='hamahangi', dateTimeAnjam=ms(NOW), visitHoldUntil=ms(target), ) self.customer.refresh_from_db() self.assertEqual(self.customer.visit_hold_until, end_of_local_day(target)) self.assertEqual(self.last_action().status, Action.Status.WAITE) def test_hold_cannot_be_in_the_past(self): """تاریخ تعویقِ گذشته رد می‌شود و هیچ اقدامی ثبت نمی‌شود.""" response = self.post_action( actionType='hamahangi', dateTimeAnjam=ms(NOW), visitHoldUntil=ms(at(-1)), is_done='on', ) self.assertEqual(response.status_code, 302) self.assertIsNone(self.last_action()) self.customer.refresh_from_db() self.assertIsNone(self.customer.visit_hold_until) def test_hold_cannot_exceed_six_months(self): """سقف تعویق ۱۸۳ روز (≈۶ ماه) است.""" response = self.post_action( actionType='hamahangi', dateTimeAnjam=ms(NOW), visitHoldUntil=ms(at(200)), is_done='on', ) self.assertEqual(response.status_code, 302) self.assertIsNone(self.last_action()) self.customer.refresh_from_db() self.assertIsNone(self.customer.visit_hold_until) def test_hold_only_moves_forward(self): """ثبت هماهنگی دوم با تاریخ کوتاه‌تر، قرار دورتر را عقب نمی‌کشد.""" self.post_action( actionType='hamahangi', dateTimeAnjam=ms(NOW), visitHoldUntil=ms(at(10)), is_done='on', ) self.customer.refresh_from_db() far = self.customer.visit_hold_until self.post_action( actionType='hamahangi', dateTimeAnjam=ms(NOW), visitHoldUntil=ms(at(3)), is_done='on', ) self.customer.refresh_from_db() self.assertEqual(self.customer.visit_hold_until, far) class EntezarDecisionSimulationTests(SimulationBase): """اثر فیلدها روی فهرست انتظار، برچسب وضعیت و ستون «قرار دستی».""" def test_two_sources_render_two_appointment_badge(self): """هماهنگیِ آینده + قرار دستی ⇒ برچسب «۲ قرار» (source_count). ترکیب واقعیِ دادهٔ تولیدی: ``coordination+manual``. """ make_action(self.customer, 'hamahangi', 'finish', action_dt=at(30), finish_dt=NOW) self.post_action( actionType='hamahangi', dateTimeAnjam=ms(at(30)), visitHoldUntil=ms(at(10)), is_done='on', ) ids, response = self.stale_ids('all') self.assertIn(self.customer.pk, ids) row = response.context['appointment_rows'][self.customer.pk] self.assertEqual(row['source_count'], 2) self.assertIsNotNone(row['manual']) self.assertIsNotNone(row['coordination']) self.assertIsNone(row['scheduled']) self.assertIn('2 قرار', response.content.decode()) def test_manual_only_badge_and_column(self): """قرار دستی به‌تنهایی: برچسب «قرار دستی» و تاریخ در ستون همان ستون.""" self.post_action( actionType='hamahangi', dateTimeAnjam=ms(at(-1)), visitHoldUntil=ms(at(10)), is_done='on', ) entry = manual_holds_map() self.assertIn(self.customer.pk, entry) ids, response = self.stale_ids('all') self.assertIn(self.customer.pk, ids) row = response.context['appointment_rows'][self.customer.pk] self.assertEqual(row['source_count'], 1) self.assertIsNotNone(row['manual']) self.assertIsNone(row['coordination']) self.assertIn('قرار دستی', response.content.decode()) self.assertIn(jalali(at(10)), response.content.decode()) def test_visit_after_coordination_cancels_manual_hold(self): """قاعدهٔ «آخرین فعالیت باید هماهنگی باشد»: بازدید بعدی قرار را باطل می‌کند.""" self.post_action( actionType='hamahangi', dateTimeAnjam=ms(at(-1)), visitHoldUntil=ms(at(10)), is_done='on', ) self.assertIn(self.customer.pk, manual_holds_map()) # ثبت بازدید پس از هماهنگی ⇒ ستون هست ولی بی‌اعتبار (قاعدهٔ «آخرین فعالیت»). # تاریخ بازدید را قدیمی می‌گیریم تا مشتری در نمای «عقب‌افتاده» بماند؛ # ``createdDT`` همیشه «الان» است و همین، قرار را باطل می‌کند. make_action(self.customer, 'bazdid', 'finish', action_dt=at(-330), finish_dt=at(-330)) self.assertNotIn(self.customer.pk, manual_holds_map()) self.customer.refresh_from_db() self.assertIsNotNone( self.customer.visit_hold_until, msg='ستون پاک نمی‌شود؛ فقط اعتبارش را از دست می‌دهد', ) _, response = self.stale_ids('all') row = response.context['appointment_rows'][self.customer.pk] self.assertEqual(row['source_count'], 0) self.assertIn('بدون قرار', response.content.decode()) def test_hold_date_is_the_followup_day(self): """قرار دستی، مشتری را فقط در همان روز به نمای «نیازمند پیگیری» می‌آورد.""" self.post_action( actionType='hamahangi', dateTimeAnjam=ms(at(-1)), visitHoldUntil=ms(NOW), is_done='on', ) today = timezone.localdate() self.assertIn(self.customer.pk, followups_on_date(today)) self.assertNotIn(self.customer.pk, followups_on_date(today + timedelta(days=1))) ids, _ = self.stale_ids('today') self.assertIn(self.customer.pk, ids) def test_scheduled_future_visit_hides_customer(self): """«تاریخ انجام»ِ آیندهٔ یک بازدیدِ در انتظار ⇒ خروج از «تعیین‌تکلیف‌نشده».""" self.post_action(actionType='bazdid', dateTimeAnjam=ms(at(5))) ids, _ = self.stale_ids('uncoordinated') self.assertNotIn(self.customer.pk, ids) scheduled, response = self.stale_ids('scheduled') self.assertIn(self.customer.pk, scheduled) self.assertEqual( response.context['appointment_rows'][self.customer.pk]['source_count'], 1, ) def test_past_coordination_action_datetime_does_not_hide(self): """«تاریخ انجام»ِ گذشته برای هماهنگی، چون «زمان تماس» است، پنهان نمی‌کند.""" make_action(self.customer, 'hamahangi', 'finish', action_dt=at(-2), finish_dt=at(-2)) self.assertNotIn(self.customer.pk, appointment_sources_map()) ids, _ = self.stale_ids('uncoordinated') self.assertIn(self.customer.pk, ids) def test_same_day_sources_are_one_appointment(self): """هماهنگی + قرار دستیِ همان روز ⇒ «۲ قرار» نادرست است؛ یک قرار است.""" self.post_action( actionType='hamahangi', dateTimeAnjam=ms(at(10)), visitHoldUntil=ms(at(10)), is_done='on', ) _, response = self.stale_ids('all') row = response.context['appointment_rows'][self.customer.pk] self.assertEqual(row['source_count'], 1) html = response.content.decode() self.assertNotIn('2 قرار', html) self.assertIn('قرار دستی', html) def test_two_different_days_render_two_appointment_badge(self): """هماهنگی + قرار دستی در دو روز متفاوت ⇒ «۲ قرار» درست است.""" self.post_action( actionType='hamahangi', dateTimeAnjam=ms(at(30)), visitHoldUntil=ms(at(10)), is_done='on', ) _, response = self.stale_ids('all') self.assertEqual( response.context['appointment_rows'][self.customer.pk]['source_count'], 2, ) self.assertIn('2 قرار', response.content.decode()) class AppointmentLifetimeSimulationTests(SimulationBase): """عمر «قرار دستی»: چه چیزی آن را باطل می‌کند و چه چیزی نه. قاعدهٔ کسب‌وکار: فقط بازدید/نصب قرار را جایگزین می‌کند. یک پرداخت یا یک تماس ثبت‌شده نباید قرارِ توافق‌شده را بی‌صدا از بین ببرد. """ def _hold_customer(self): self.post_action( actionType='hamahangi', dateTimeAnjam=ms(at(-1)), visitHoldUntil=ms(at(12)), is_done='on', ) self.assertIn(self.customer.pk, manual_holds_map()) return self.customer def test_payment_does_not_kill_appointment(self): self._hold_customer() make_action(self.customer, 'vosol', 'finish', action_dt=NOW, finish_dt=NOW) self.assertIn( self.customer.pk, manual_holds_map(), msg='ثبت سند وصول نباید قرار بازدید را باطل کند', ) def test_phone_call_does_not_kill_appointment(self): self._hold_customer() make_action(self.customer, 'inCall', 'finish', action_dt=NOW, finish_dt=NOW) make_action(self.customer, 'outCall', 'finish', action_dt=NOW, finish_dt=NOW) self.assertIn(self.customer.pk, manual_holds_map()) def test_visit_kills_appointment(self): self._hold_customer() make_action(self.customer, 'bazdid', 'finish', action_dt=NOW, finish_dt=NOW) self.assertNotIn(self.customer.pk, manual_holds_map()) def test_install_kills_appointment(self): self._hold_customer() make_action(self.customer, 'nasb', 'finish', action_dt=NOW, finish_dt=NOW) self.assertNotIn(self.customer.pk, manual_holds_map()) def test_same_microsecond_visit_still_kills_appointment(self): """ثبت بازدید در همان لحظهٔ هماهنگی هم قرار را باطل می‌کند. ``createdDT`` روی SQLite/ویندوز دقت کافی ندارد و دو رکورد پشت‌سرهم می‌توانند مقدار یکسان بگیرند؛ مقایسه با ``id`` به‌عنوان مرز تساوی، این حالت را هم درست تشخیص می‌دهد (قبلاً تست به زمان اجرا وابسته و ناپایدار بود). """ from admin_panel.models import Action self._hold_customer() visit = make_action(self.customer, 'bazdid', 'finish', action_dt=NOW, finish_dt=NOW) # هر دو رکورد همان createdDT را می‌گیرند (بازسازی شرط مسابقه). stamp = Action.objects.get( customer=self.customer, actionType='hamahangi', ).createdDT Action.objects.filter(pk=visit.pk).update(createdDT=stamp) self.assertNotIn(self.customer.pk, manual_holds_map()) class FollowupDaySimulationTests(SimulationBase): """فهرست «نیازمند پیگیری امروز»: کارِ باقی‌مانده، نه آرشیو.""" def test_visit_done_today_clears_today_row(self): make_action(self.customer, 'hamahangi', 'finish', action_dt=NOW, finish_dt=NOW) self.assertIn(self.customer.pk, followups_on_date(timezone.localdate())) make_action(self.customer, 'bazdid', 'finish', action_dt=NOW, finish_dt=NOW) self.assertNotIn(self.customer.pk, followups_on_date(timezone.localdate())) ids, _ = self.stale_ids('today') self.assertNotIn(self.customer.pk, ids) def test_visit_yesterday_does_not_clear_today_row(self): """بازدید دیروز، موعدِ امروز را نمی‌بندد (اگر موعد تازه‌ای گذاشته شده).""" make_action(self.customer, 'hamahangi', 'finish', action_dt=NOW, finish_dt=NOW) make_action(self.customer, 'bazdid', 'finish', action_dt=at(-1), finish_dt=at(-1)) self.assertIn(self.customer.pk, followups_on_date(timezone.localdate())) def test_browsing_past_day_keeps_history(self): """مرور یک روز گذشته، ردیف را با بازدیدِ بعدتر حذف نمی‌کند.""" yesterday = timezone.localdate() - timedelta(days=1) make_action(self.customer, 'hamahangi', 'finish', action_dt=at(-1), finish_dt=at(-1)) make_action(self.customer, 'bazdid', 'finish', action_dt=NOW, finish_dt=NOW) self.assertIn(self.customer.pk, followups_on_date(yesterday)) # --- «نصب» هم مثل بازدید فهرست کار را می‌بندد --- # قاعدهٔ کسب‌وکار: نصب و بازدید هر دو چرخه را از نو شروع می‌کنند # (``_CYCLE_RESET_TYPES``) و قرار دستی را باطل می‌کنند؛ پس فهرست «نیازمند # پیگیری» هم باید با هر دو بسته شود. def test_install_done_today_clears_today_row(self): """«نصبِ انجام‌شدهٔ امروز» هم ردیف امروز را می‌بندد (مثل بازدید).""" make_action(self.customer, 'hamahangi', 'finish', action_dt=NOW, finish_dt=NOW) self.assertIn(self.customer.pk, followups_on_date(timezone.localdate())) make_action(self.customer, 'nasb', 'finish', action_dt=NOW, finish_dt=NOW) self.assertNotIn(self.customer.pk, followups_on_date(timezone.localdate())) ids, _ = self.stale_ids('today') self.assertNotIn(self.customer.pk, ids) def test_scheduled_install_hides_today_row(self): """«نصب» با وضعیت waite و تاریخ **آینده** ⇒ ردیف امروز پنهان می‌شود.""" make_action(self.customer, 'hamahangi', 'finish', action_dt=NOW, finish_dt=NOW) self.assertIn(self.customer.pk, followups_on_date(timezone.localdate())) make_action(self.customer, 'nasb', 'waite', action_dt=at(7)) self.assertNotIn(self.customer.pk, followups_on_date(timezone.localdate())) ids, _ = self.stale_ids('today') self.assertNotIn(self.customer.pk, ids) def test_old_pending_install_does_not_hide(self): """نصبِ «در انتظار» با تاریخ گذشته قرار فعال نیست ⇒ ردیف می‌ماند.""" make_action(self.customer, 'hamahangi', 'finish', action_dt=NOW, finish_dt=NOW) make_action(self.customer, 'nasb', 'waite', action_dt=at(-30)) self.assertIn(self.customer.pk, followups_on_date(timezone.localdate())) def test_install_yesterday_does_not_clear_today_row(self): """نصبِ دیروز، موعدِ امروز را نمی‌بندد (اگر موعد تازه‌ای گذاشته شده).""" make_action(self.customer, 'hamahangi', 'finish', action_dt=NOW, finish_dt=NOW) make_action(self.customer, 'nasb', 'finish', action_dt=at(-1), finish_dt=at(-1)) self.assertIn(self.customer.pk, followups_on_date(timezone.localdate())) class TaskListBoundaryTests(SimulationBase): """«لیست وظایف امروز»: مرزهای روز به وقت محلی و aware. قبلاً مرزها با رشته‌های naive ساخته می‌شدند و به تایم‌زون پیش‌فرض وابسته بودند؛ وظیفه‌ای در ۰۰:۳۰ امروز یا ۲۳:۳۰ دیروز می‌توانست در/خارج از فهرست اشتباه بیفتد. این تست مرزها را با وقت محلی قفل می‌کند. """ def _task(self, moment, *, done=False): return Action.objects.create( customer=self.customer, actionType='bazdid', forUser=self.user, actionDateTime=moment, status=Action.Status.FINISH if done else Action.Status.WAITE, ) def _task_ids(self, url): response = self.client.get(url) self.assertEqual(response.status_code, 200) return {row.id for row in response.context['actions']} def test_local_day_boundaries(self): today = timezone.localdate() early = timezone.make_aware(datetime.datetime.combine( today, datetime.time(0, 30))) late = timezone.make_aware(datetime.datetime.combine( today, datetime.time(23, 30))) yesterday_night = timezone.make_aware(datetime.datetime.combine( today - timedelta(days=1), datetime.time(23, 30))) early_task = self._task(early) late_task = self._task(late) old_task = self._task(yesterday_night) ids = self._task_ids('/panel/ActionList/today') self.assertIn(early_task.pk, ids) self.assertIn(late_task.pk, ids) self.assertNotIn(old_task.pk, ids, msg='وظیفهٔ ۲۳:۳۰ دیروز نباید «امروز» باشد') # فهرست «عقب‌افتاده» همان کاربر: فقط وظیفهٔ دیشب (کلید ``actionPr``). response = self.client.get('/panel/ActionList/%d/today' % self.user.pk) backlog = {row.id for row in response.context['actionPr']} self.assertNotIn(early_task.pk, backlog) self.assertIn(old_task.pk, backlog) def test_finished_task_is_not_in_today_list(self): when = timezone.make_aware(datetime.datetime.combine( timezone.localdate(), datetime.time(10, 0))) done = self._task(when, done=True) self.assertNotIn(done.pk, self._task_ids('/panel/ActionList/today')) class CacheTableWriteTests(SimulationBase): """کارت داشبورد: مقدار کش با شمارندهٔ «تعیین‌تکلیف نشده» هم‌راستا می‌ماند.""" def test_dashboard_counter_matches_uncoordinated(self): from admin_panel.models import CacheTable CacheTable.objects.update_or_create( key='9mahbazdid', defaults={'value': '0'}, ) response = self.client.get('/panel/entezar/?view=all') self.assertEqual(response.status_code, 200) expected = str(response.context['counts']['uncoordinated']) self.assertEqual(CacheTable.objects.get(key='9mahbazdid').value, expected) class HoldBeforeWorkDateSimulationTests(SimulationBase): """سناریوی «تعویق پیش از تاریخ کار» (۱۴۰۵/۰۸/۰۳ در برابر ۱۴۰۵/۰۸/۰۵). تاریخ‌ها نسبی‌اند تا تست به روز اجرا وابسته نباشد؛ همان فاصله‌ها: تاریخ کار = امروز+۵ · تعویق = امروز+۳ (پایان روز). نکتهٔ مهم: هیچ اعتبارسنجی‌ای تعویقِ **زودتر از تاریخ کار** را رد نمی‌کند؛ هر دو فقط باید در آینده باشند. این تست رفتار فعلی را قفل می‌کند. """ def _create(self, *, done): payload = { 'actionType': 'hamahangi', 'dateTimeAnjam': ms(at(5)), 'visitHoldUntil': ms(at(3)), } if done: payload['is_done'] = 'on' self.post_action(**payload) self.customer.refresh_from_db() return self.last_action() def test_earlier_hold_is_accepted(self): """تعویقِ قبل از تاریخ کار پذیرفته می‌شود و به پایان همان روز می‌رسد.""" action = self._create(done=True) self.assertIsNotNone(action) self.assertEqual(self.customer.visit_hold_until, end_of_local_day(at(3))) self.assertEqual(action.actionDateTime, at(5)) def test_badge_is_two_appointments(self): """دو تاریخ متفاوت ⇒ «۲ قرار»، هرچند در عمل یک توافق است.""" self._create(done=True) _, response = self.stale_ids('all') row = response.context['appointment_rows'][self.customer.pk] self.assertEqual(row['source_count'], 2) self.assertIn('2 قرار', response.content.decode()) def test_hold_day_creates_an_extra_followup_row(self): """روزِ تعویق، مشتری در فهرست «نیازمند پیگیری» می‌آید، در حالی که قرار اصلی هنوز دو روز بعد است (ردیفِ کاریِ تکراری).""" self._create(done=True) today = timezone.localdate() self.assertIn(self.customer.pk, followups_on_date(today + timedelta(days=3))) self.assertIn(self.customer.pk, followups_on_date(today + timedelta(days=5))) # در روزِ میانی (بین تعویق و تاریخ کار) قرارِ هماهنگی پابرجاست. self.assertNotIn(self.customer.pk, followups_on_date(today + timedelta(days=4))) def test_scanner_stays_quiet_between_hold_and_work_date(self): """در فاصلهٔ پایان تعویق تا تاریخ کار، «قرار بعدی» هماهنگی است ⇒ یادآوری دوره‌ای ارسال نمی‌شود (صفحه و پیامک واگرا نمی‌شوند).""" self._create(done=True) for offset in (0, 2, 3, 4, 5): moment = at(offset) self.assertIn( self.customer.pk, next_appointment_map(now=moment), msg='در روز +%d باید قرار فعال باشد' % offset, ) def test_customer_falls_into_missed_right_after_work_date(self): """چون تعویق قبل از تاریخ کار تمام شده، فردای تاریخ کار مشتری «بدون قرار» و «عقب‌افتاده» است (مسیر تصمیم محصولی).""" self._create(done=True) after = at(6) self.assertNotIn(self.customer.pk, appointment_sources_map(now=after)) self.assertIn( self.customer.pk, overdue_followups(now=after, target_date=after.date()), ) def test_done_and_open_have_same_hold_effect(self): """تیک «انجام شد» روی قرار/پنهان‌سازی اثری ندارد؛ فقط وضعیت عوض می‌شود.""" action_open = self._create(done=False) hold_open = self.customer.visit_hold_until self.assertEqual(action_open.status, Action.Status.WAITE) Action.objects.filter(customer=self.customer).delete() Customer.objects.filter(pk=self.customer.pk).update(visit_hold_until=None) self.customer.refresh_from_db() action_done = self._create(done=True) self.assertEqual(action_done.status, Action.Status.FINISH) self.assertEqual(self.customer.visit_hold_until, hold_open) def test_done_leaves_task_list_and_enters_survey(self): """تیک‌دار: از «لیست وظایف باز» خارج و به صف «نظرسنجی» اضافه می‌شود.""" action = self._create(done=True) self.assertEqual( Action.objects.filter(pk=action.pk, status=Action.Status.WAITE).count(), 0, ) survey = Action.objects.exclude(actionType='vosol').filter( status=Action.Status.FINISH, nazarDT__isnull=True, ) self.assertIn(action.pk, {row.pk for row in survey}) def test_open_stays_in_task_list_without_survey(self): """بدون تیک: وظیفه باز می‌ماند (در لیست وظایف/شمارندهٔ داشبورد) و هیچ نظرسنجی‌ای ساخته نمی‌شود.""" action = self._create(done=False) self.assertEqual( Action.objects.filter(pk=action.pk, status=Action.Status.WAITE).count(), 1, ) survey = Action.objects.exclude(actionType='vosol').filter( status=Action.Status.FINISH, nazarDT__isnull=True, ) self.assertNotIn(action.pk, {row.pk for row in survey}) def test_done_without_finish_date_records_now_not_work_date(self): """تاریخ اتمام خالی ⇒ زمان ثبت؛ نه تاریخِ کار (تاریخ کار ۲ روز بعدتر است).""" action = self._create(done=True) self.assertLess(action.finishDateTime, action.actionDateTime) class HoldBackwardConfirmTests(SimulationBase): """«عقب‌بردن» تاریخ قرار: پیام شفاف + تأیید صریح کاربر. قاعده: سیستم بی‌صدا تاریخ را عقب نمی‌برد؛ کاربر باید دلیل را ببیند و اگر خواست، خودش با تیک ``force_hold`` اجازه بدهد. """ def _messages(self, response): return [str(m) for m in response.context['messages']] def _make_hold(self, days=10): """یک هماهنگی با قرار ۱۰ روز بعد؛ ستون مشتری همان می‌شود.""" self.post_action( actionType='hamahangi', dateTimeAnjam=ms(at(-1)), visitHoldUntil=ms(at(days)), is_done='on', ) self.customer.refresh_from_db() return self.customer.visit_hold_until def _edit_hold(self, action, *, days, force=False): payload = { 'actionType': 'hamahangi', 'dateTimeAnjam': ms(at(-1)), 'visitHoldUntil': ms(at(days)), 'is_done': 'on', 'userID': self.user.pk, 'desc': 'ویرایش قرار', } if force: payload['force_hold'] = '1' return self.client.post( '/panel/customer/%d/action/%d/edit' % (self.customer.pk, action.pk), payload, HTTP_REFERER='/panel/customer/%d/action' % self.customer.pk, follow=True, ) def test_earlier_date_is_not_saved_and_user_is_told(self): hold = self._make_hold(days=10) action = self.last_action() response = self._edit_hold(action, days=3) self.customer.refresh_from_db() self.assertEqual(self.customer.visit_hold_until, hold, msg='قرار نباید عقب برود') text = ' | '.join(self._messages(response)) self.assertIn('ذخیره نشد', text) self.assertIn(jalali(at(10)), text) # قرار فعلی self.assertIn('تاریخ قرار را عقب ببر', text) # پیام باید در خودِ صفحه هم رندر شود (کاربر آن را می‌بیند)، نه فقط در session. self.assertIn('ذخیره نشد', response.content.decode()) def test_user_confirmation_writes_the_earlier_date(self): self._make_hold(days=10) action = self.last_action() self._edit_hold(action, days=3, force=True) self.customer.refresh_from_db() self.assertEqual( self.customer.visit_hold_until, end_of_local_day(at(3)), msg='پس از تأیید صریح، تاریخ عقب‌تر باید ذخیره شود', ) def test_forward_date_needs_no_confirmation(self): self._make_hold(days=3) action = self.last_action() response = self._edit_hold(action, days=20) self.customer.refresh_from_db() self.assertEqual(self.customer.visit_hold_until, end_of_local_day(at(20))) self.assertNotIn('ذخیره نشد', ' | '.join(self._messages(response))) def test_same_day_is_silent(self): hold = self._make_hold(days=10) action = self.last_action() response = self._edit_hold(action, days=10) self.customer.refresh_from_db() self.assertEqual(self.customer.visit_hold_until, hold) self.assertNotIn('ذخیره نشد', ' | '.join(self._messages(response))) def test_create_does_not_move_the_hold_backward(self): """در «ایجاد» عقب‌بردن قرار مجاز نیست — حتی با force_hold. وظیفهٔ تازه ثبت می‌شود (کار روزانه‌اش را انجام می‌دهد) ولی قرارِ ثبت‌شدهٔ قبلی — شاید کارِ کارشناس دیگری — کوتاه نمی‌شود. """ hold = self._make_hold(days=10) before = Action.objects.filter(customer=self.customer).count() response = self.client.post( '/panel/customer/%d/action/new' % self.customer.pk, {'actionType': 'hamahangi', 'dateTimeAnjam': ms(at(-1)), 'visitHoldUntil': ms(at(4)), 'is_done': 'on', 'userID': self.user.pk, 'desc': 'دوم', 'force_hold': '1'}, follow=True, ) self.customer.refresh_from_db() self.assertEqual(self.customer.visit_hold_until, hold, msg='در ایجاد، حتی با force_hold نباید عقب برود') self.assertEqual( Action.objects.filter(customer=self.customer).count(), before + 1, msg='خودِ وظیفه باید ثبت شود', ) text = ' | '.join(self._messages(response)) self.assertIn('ذخیره نشد', text) self.assertIn('ویرایش کنید', text) def test_apply_visit_hold_reports_reason(self): """قرارداد تابع: دلیل شکست برگردانده می‌شود، نه True بی‌قید.""" from admin_panel.views.actions import _apply_visit_hold self._make_hold(days=10) result = _apply_visit_hold( self.customer, {'actionType': 'hamahangi', 'holdRaw': at(2), 'status': Action.Status.FINISH}, status=Action.Status.FINISH, is_new=False, ) self.assertFalse(result['ok']) self.assertEqual(result['reason'], 'forward_only') self.assertIsNotNone(result['current']) self.assertIsNotNone(result['target']) self.assertTrue(result['force_allowed'], msg='در ویرایش، عقب‌بردن مجاز است') def test_apply_visit_hold_ignores_force_on_create(self): """در ایجاد، force_hold نادیده گرفته می‌شود (قرار کوتاه نمی‌شود).""" from admin_panel.views.actions import _apply_visit_hold hold = self._make_hold(days=10) result = _apply_visit_hold( self.customer, {'actionType': 'hamahangi', 'holdRaw': at(2), 'status': Action.Status.FINISH}, status=Action.Status.FINISH, is_new=True, force_hold=True, ) self.customer.refresh_from_db() self.assertFalse(result['ok']) self.assertFalse(result['force_allowed']) self.assertEqual(self.customer.visit_hold_until, hold) def test_modal_exposes_confirmation_controls(self): """قالب: اعلانِ ساده + تیک تأیید (فقط ویرایش) + راهنمای مسیر ایجاد.""" self._make_hold(days=10) html = self.client.get( '/panel/customer/%d/action' % self.customer.pk).content.decode() self.assertIn('data-customer-hold=', html) self.assertIn('name="force_hold"', html) self.assertIn('id="holdBackwardNotice"', html) self.assertIn('id="forceHoldCheck"', html) # چک‌باکس (نه دکمه) self.assertIn('type="checkbox"', html) self.assertIn('id="hbCurrent"', html) # «قرار فعلی» در اعلان self.assertIn('id="hbForceRow"', html) # تیک فقط در ویرایش self.assertIn('id="hbCreateHint"', html) # مسیر ایجاد: ویرایش وظیفه self.assertIn('تاریخ قرار را عقب ببر', html) # متن کوتاه و روشن self.assertIn('syncHoldBackward', html) # کلاس مستقل از پنل «انجام شد» تا CSS آن دکمه‌ها را پنهان نکند. self.assertIn('ax-holdback', html) self.assertNotIn('holdBackwardYes', html) class MissedViewSimulationTests(SimulationBase): """رفتار فهرست ``?view=today&missed=1`` پس از رفع باگ. قاعدهٔ کسب‌وکار: این فهرست فقط «پیگیری‌های واقعاً باقی‌مانده» را نشان می‌دهد. هر مشتری که (الف) بازدید یا نصبش از موعد به بعد انجام شده، یا (ب) قرار دستیِ معتبر دارد، یا (ج) بازدید/نصبِ در انتظار از آن روز دارد، از فهرست خارج می‌شود. """ URL = '/panel/entezar/?view=today&missed=1' def missed(self): response = self.client.get(self.URL) self.assertEqual(response.status_code, 200) return {c.id for c in response.context['customers']}, response def test_finished_visit_after_coordination_is_not_listed(self): """هماهنگی دیروز + بازدید انجام‌شدهٔ امروز ⇒ نباید در missed بیاید.""" make_action(self.customer, 'hamahangi', 'finish', action_dt=at(-1, -2), finish_dt=at(-1)) make_action(self.customer, 'bazdid', 'finish', action_dt=NOW, finish_dt=NOW) ids, response = self.missed() self.assertNotIn(self.customer.pk, ids) self.assertNotIn(self.customer.pk, overdue_followups()) self.assertEqual(response.context['counts']['followup_overdue'], 0) def test_visit_before_due_does_not_clear(self): """بازدیدِ قدیمی‌تر از موعد، پیگیریِ جدید را نمی‌بندد.""" make_action(self.customer, 'bazdid', 'finish', action_dt=at(-330), finish_dt=at(-330)) make_action(self.customer, 'hamahangi', 'finish', action_dt=at(-2), finish_dt=at(-2)) ids, _ = self.missed() self.assertIn(self.customer.pk, ids) def test_future_manual_hold_is_not_listed(self): """«تا تاریخ پیگیری نشود» معتبر ⇒ نه در missed، نه در شمارندهٔ قرمز.""" self.post_action( actionType='hamahangi', dateTimeAnjam=ms(at(-3)), visitHoldUntil=ms(at(10)), is_done='on', ) self.assertIn(self.customer.pk, manual_holds_map()) ids, response = self.missed() self.assertNotIn(self.customer.pk, ids) self.assertEqual(response.context['counts']['followup_overdue'], 0) def test_dead_manual_hold_is_still_listed(self): """قرارِ بی‌اعتبار (بازدید بعد از آن ثبت شده) مانع نمایش نیست.""" self.post_action( actionType='hamahangi', dateTimeAnjam=ms(at(-3)), visitHoldUntil=ms(at(10)), is_done='on', ) # بازدیدِ قدیمی‌تر از موعد هماهنگی: قرار را باطل می‌کند ولی پیگیری را نمی‌بندد. make_action(self.customer, 'bazdid', 'finish', action_dt=at(-400), finish_dt=at(-400)) self.assertNotIn(self.customer.pk, manual_holds_map()) ids, _ = self.missed() self.assertIn(self.customer.pk, ids) def test_pending_visit_removes_from_missed(self): """قاعدهٔ موجود: بازدیدِ «در انتظار» با تاریخ امروز، مشتری را خارج می‌کند.""" make_action(self.customer, 'hamahangi', 'finish', action_dt=at(-3), finish_dt=at(-3)) make_action(self.customer, 'bazdid', 'waite', action_dt=at(0, 1)) ids, _ = self.missed() self.assertNotIn(self.customer.pk, ids) def test_pending_install_removes_from_missed(self): """«نصبِ» در انتظار با تاریخ آینده هم مشتری را از missed خارج می‌کند.""" make_action(self.customer, 'hamahangi', 'finish', action_dt=at(-3), finish_dt=at(-3)) make_action(self.customer, 'nasb', 'waite', action_dt=at(5)) ids, _ = self.missed() self.assertNotIn(self.customer.pk, ids) def test_finished_install_after_coordination_is_not_listed(self): """هماهنگی دیروز + نصب انجام‌شدهٔ امروز ⇒ نباید در missed بیاید.""" make_action(self.customer, 'hamahangi', 'finish', action_dt=at(-1, -2), finish_dt=at(-1)) make_action(self.customer, 'nasb', 'finish', action_dt=NOW, finish_dt=NOW) ids, response = self.missed() self.assertNotIn(self.customer.pk, ids) self.assertNotIn(self.customer.pk, overdue_followups()) self.assertEqual(response.context['counts']['followup_overdue'], 0) def test_old_pending_visit_does_not_remove(self): """بازدیدِ در انتظارِ قدیمی‌تر از موعد، قرار فعال نیست ⇒ در missed می‌ماند.""" make_action(self.customer, 'hamahangi', 'finish', action_dt=at(-30), finish_dt=at(-30)) make_action(self.customer, 'bazdid', 'waite', action_dt=at(-40)) ids, _ = self.missed() self.assertIn(self.customer.pk, ids) def test_fresh_coordination_removes_from_missed(self): """موعدِ تازه (هماهنگی امروز/آینده) ⇒ دیگر «عقب‌افتاده» نیست.""" make_action(self.customer, 'hamahangi', 'finish', action_dt=at(-30), finish_dt=at(-30)) make_action(self.customer, 'hamahangi', 'waite', action_dt=at(5)) ids, _ = self.missed() self.assertNotIn(self.customer.pk, ids) def test_overdue_counter_equals_missed_rows(self): """عدد هشدار سرخ = تعداد ردیف‌های همان فهرست (سازگاری شمارنده و لیست).""" make_action(self.customer, 'hamahangi', 'finish', action_dt=at(-2), finish_dt=at(-2)) ids, response = self.missed() self.assertEqual(response.context['counts']['followup_overdue'], len(ids)) self.assertIn(self.customer.pk, ids) def test_hold_only_moves_forward(self): """ثبت هماهنگی دوم با تاریخ کوتاه‌تر، قرار دورتر را عقب نمی‌کشد.""" self.post_action( actionType='hamahangi', dateTimeAnjam=ms(NOW), visitHoldUntil=ms(at(10)), is_done='on', ) self.customer.refresh_from_db() far = self.customer.visit_hold_until self.post_action( actionType='hamahangi', dateTimeAnjam=ms(NOW), visitHoldUntil=ms(at(3)), is_done='on', ) self.customer.refresh_from_db() class SmsSimulationTests(SimulationBase): """پیامک‌های متأثر از دو فیلد: یادآوری دوره‌ای ``visit`` و ``visit_created``.""" def setUp(self): super().setUp() config = SMSProviderConfig.load() config.scan_since = timezone.now() - timedelta(days=500) config.allow_backlog = True config.backlog_max = 100 config.max_per_run = 200 config.is_enabled = True config.save() SMSTemplate.objects.filter(sms_type=SMSType.VISIT).update(is_enabled=True) SMSTemplate.objects.filter( sms_type=SMSType.VISIT_CREATED, ).update(is_enabled=True) def scan(self, moment=None): moment = moment or NOW return scan_visit(moment.date(), now=moment) def created_sms(self): return SMSQueue.objects.filter( customer=self.customer, sms_type=SMSType.VISIT_CREATED, ) def test_baseline_visit_reminder_is_queued(self): """مشتریِ عقب‌افتاده بدون قرار ⇒ یادآوری دوره‌ای در صف قرار می‌گیرد.""" self.assertEqual(self.scan(), 1) self.assertEqual( SMSQueue.objects.filter(customer=self.customer, sms_type=SMSType.VISIT).count(), 1, ) def test_hold_until_suppresses_visit_reminder(self): """«تا تاریخ پیگیری نشود» (با هماهنگی فعال) ⇒ یادآوری دوره‌ای ارسال نمی‌شود.""" self.post_action( actionType='hamahangi', dateTimeAnjam=ms(NOW), visitHoldUntil=ms(at(10)), is_done='on', ) self.assertEqual(self.scan(), 0) # پس از پایان قرار، در اجرای بعدی اسکنر دوباره واجد شرط می‌شود. later = NOW + timedelta(days=12) self.assertEqual(self.scan(later), 1) def test_hold_column_without_coordination_is_inert(self): """ستون تعویق به‌تنهایی (بدون اقدام هماهنگی) هیچ اثری ندارد ⇒ پیامک می‌رود.""" self.customer.visit_hold_until = end_of_local_day(at(10)) self.customer.save(update_fields=['visit_hold_until']) self.assertEqual(self.scan(), 1) def test_scheduled_future_visit_suppresses_reminder(self): """«تاریخ انجام»ِ آینده روی بازدید در انتظار ⇒ یادآوری دوره‌ای قطع می‌شود.""" self.post_action(actionType='bazdid', dateTimeAnjam=ms(at(5))) self.assertEqual(self.scan(), 0) def test_future_coordination_suppresses_reminder(self): """هماهنگی با «تاریخ انجام» آینده ⇒ یادآوری دوره‌ای قطع می‌شود.""" self.post_action(actionType='hamahangi', dateTimeAnjam=ms(at(5))) self.assertEqual(self.scan(), 0) def test_past_coordination_date_does_not_suppress_reminder(self): """«تاریخ انجام»ِ گذشته (زمان تماس) ⇒ قرار فعال نیست؛ پیامک می‌رود.""" make_action(self.customer, 'hamahangi', 'finish', action_dt=at(-1), finish_dt=at(-1)) self.assertEqual(self.scan(), 1) def test_visit_action_sends_visit_created_with_action_datetime(self): """اقدام «بازدید» ⇒ پیامک ``visit_created`` با تاریخِ فیلد «تاریخ انجام».""" when = at(1) self.post_action(actionType='bazdid', dateTimeAnjam=ms(when)) self.assertEqual(self.created_sms().count(), 1) message = self.created_sms().first().message self.assertIn(format_jalali(when), message) def test_coordination_does_not_send_visit_created(self): """«هماهنگی» هیچ پیامک ``visit_created`` نمی‌فرستد (فقط بازدید).""" self.post_action(actionType='hamahangi', dateTimeAnjam=ms(at(1)), visitHoldUntil=ms(at(10)), is_done='on') self.assertEqual(self.created_sms().count(), 0) def test_hold_until_does_not_block_visit_created(self): """قرار دستی، پیامک رویدادی «ثبت بازدید» را مسدود نمی‌کند.""" self.post_action(actionType='hamahangi', dateTimeAnjam=ms(NOW), visitHoldUntil=ms(at(10)), is_done='on') self.post_action(actionType='bazdid', dateTimeAnjam=ms(at(1))) self.assertEqual(self.created_sms().count(), 1) def test_creating_visit_clears_hold_column(self): """ثبت اقدام غیرهماهنگی (بازدید) در حالت ایجاد، ستون قرار را پاک می‌کند.""" self.post_action(actionType='hamahangi', dateTimeAnjam=ms(NOW), visitHoldUntil=ms(at(10)), is_done='on') self.customer.refresh_from_db() self.assertIsNotNone(self.customer.visit_hold_until) self.post_action(actionType='bazdid', dateTimeAnjam=ms(at(1))) self.customer.refresh_from_db() self.assertIsNone(self.customer.visit_hold_until)