946 lines
46 KiB
Python
946 lines
46 KiB
Python
"""شبیهسازیِ رفتار «اقدام / هماهنگی / قرار» روی دادهٔ ساختگی.
|
|
|
|
این فایل، کدِ شبیهسازیِ سند ``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())
|
|
|
|
|
|
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))
|
|
|
|
|
|
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_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)
|