improve entezar bazdid

This commit is contained in:
javad committed 2026-10-20 19:02:34 +03:30
1 parent 1fd27d0445
commit 2aee85593f
18 files changed
+10271 -7558

No files matched your search

+816
View File
@@ -0,0 +1,816 @@
"""تست‌های «آخرین بازدید» (visit_policy) و دستور ترمیم.
این ماژول قواعد طلایی زیر را قفل می‌کند:
* فقط اقدام ``bazdid + finish`` در محاسبهٔ آخرین بازدید دیده می‌شود؛
«هماهنگی» هرگز.
* تاریخ مؤثر = ``actionDateTime`` اگر از حالا جلوتر نباشد، وگرنه ``finishDateTime``.
* بازمحاسبه و ترمیم، هیچ رکورد اقدامی را تغییر نمی‌دهند.
* بازدید انجام‌شده با تاریخ بیش از ۳۰ روز آینده در لایهٔ HTTP رد می‌شود.
"""
import datetime
import io
import os
from datetime import timedelta
from pathlib import Path
from django.core.exceptions import ValidationError
from django.core.management import CommandError, call_command
from django.db import connection
from django.test import TestCase
from django.utils import timezone
from admin_panel.models import Action, Customer
from admin_panel.visit_policy import (active_deferral, as_aware, last_visit_dt,
last_visit_map, refresh_last_bazdid,
validate_action_payload,
visit_cycle_index, visit_due_date)
from .factories import make_customer, make_user
_BASE_NOW = timezone.now()
def at(days=0, hours=0):
"""لحظهٔ aware نسبت به «حالا» (ثابت در طول یک ماژول، بدون drift میکروثانیه)."""
return _BASE_NOW + timedelta(days=days, hours=hours)
def make_visit(customer, *, action_dt=None, finish_dt=None, status='finish',
action_type='bazdid'):
return Action.objects.create(
customer=customer, actionType=action_type, status=status,
actionDateTime=action_dt, finishDateTime=finish_dt,
desc='تست', result='نتیجه',
)
def action_snapshot(action):
"""همهٔ فیلدهای یک اقدام، برای اثبات دست‌نخورده ماندن آن."""
action.refresh_from_db()
return {field.name: getattr(action, field.name)
for field in action._meta.concrete_fields}
class EffectiveVisitDateTests(TestCase):
"""قاعدهٔ «تاریخ مؤثر» و انتخاب بزرگ‌ترین مقدار."""
def setUp(self):
self.customer = make_customer('مشتری تاریخ مؤثر')
def test_finish_before_action_uses_action_when_action_is_past(self):
# هر دو در گذشته‌اند ⇒ قاعدهٔ جدید تاریخ واقعی بازدید (action) را می‌گیرد.
make_visit(self.customer, action_dt=at(-30), finish_dt=at(-31))
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-30)))
def test_future_action_falls_back_to_finish(self):
make_visit(self.customer, action_dt=at(+10), finish_dt=at(-2))
self.assertLessEqual(last_visit_dt(self.customer), timezone.now())
def test_future_action_and_future_finish_yields_nothing(self):
make_visit(self.customer, action_dt=at(+10), finish_dt=at(+11))
self.assertIsNone(last_visit_dt(self.customer))
def test_only_action(self):
make_visit(self.customer, action_dt=at(-5))
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-5)))
def test_only_finish(self):
make_visit(self.customer, finish_dt=at(-4))
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-4)))
def test_no_dates(self):
make_visit(self.customer)
self.assertIsNone(last_visit_dt(self.customer))
def test_latest_finish_wins_over_future_planned_visit(self):
make_visit(self.customer, action_dt=at(-90), finish_dt=at(-90))
make_visit(self.customer, action_dt=at(+20), finish_dt=at(-3))
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-3)))
def test_pending_visit_is_ignored(self):
make_visit(self.customer, action_dt=at(-200), finish_dt=at(-200))
make_visit(self.customer, action_dt=at(-1), status='waite')
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-200)))
class HamahangiIsolationTests(TestCase):
"""هماهنگی هرگز نباید در «آخرین بازدید» دیده شود."""
def setUp(self):
self.customer = make_customer('مشتری هماهنگی')
def test_only_hamahangi_gives_no_visit(self):
make_visit(self.customer, action_dt=at(-1), finish_dt=at(-1),
action_type='hamahangi')
self.assertIsNone(last_visit_dt(self.customer))
def test_newer_hamahangi_does_not_override_visit(self):
make_visit(self.customer, action_dt=at(-60), finish_dt=at(-60))
make_visit(self.customer, action_dt=at(-2), finish_dt=at(-2),
action_type='hamahangi')
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-60)))
def test_refresh_does_not_clear_value_without_visit(self):
Customer.objects.filter(pk=self.customer.pk).update(last_bazdid_dt=at(-10))
self.customer.refresh_from_db()
make_visit(self.customer, action_dt=at(-1), action_type='hamahangi')
changed = refresh_last_bazdid(self.customer)
self.customer.refresh_from_db()
self.assertFalse(changed)
self.assertEqual(as_aware(self.customer.last_bazdid_dt), as_aware(at(-10)))
def test_waiting_hamahangi_is_not_a_visit_but_is_a_deferral(self):
make_visit(self.customer, action_dt=at(+5), status='waite',
action_type='hamahangi')
self.assertIsNone(last_visit_dt(self.customer))
self.assertIsNotNone(active_deferral(self.customer))
def test_refresh_keeps_action_untouched(self):
action = make_visit(self.customer, action_dt=at(-30), finish_dt=at(-31))
before = action_snapshot(action)
refresh_last_bazdid(self.customer)
self.assertEqual(action_snapshot(action), before)
class RefreshLastBazdidTests(TestCase):
"""نوشتن مقدار مشتری: فقط وقتی تغییر کند، و فقط همان ستون."""
def setUp(self):
self.customer = make_customer('مشتری بازمحاسبه')
def test_writes_action_date_not_finish_date(self):
make_visit(self.customer, action_dt=at(-20), finish_dt=at(-1))
self.assertTrue(refresh_last_bazdid(self.customer))
self.customer.refresh_from_db()
self.assertLessEqual(as_aware(self.customer.last_bazdid_dt), as_aware(at(-20)))
def test_is_idempotent(self):
make_visit(self.customer, action_dt=at(-20), finish_dt=at(-20))
self.assertTrue(refresh_last_bazdid(self.customer))
self.assertFalse(refresh_last_bazdid(self.customer))
def test_other_customer_fields_untouched(self):
make_visit(self.customer, action_dt=at(-20), finish_dt=at(-20))
before = Customer.objects.values().get(pk=self.customer.pk)
refresh_last_bazdid(self.customer)
after = Customer.objects.values().get(pk=self.customer.pk)
changed = {key for key in before if before[key] != after[key]}
self.assertEqual(changed, {'last_bazdid_dt'})
class ValidationTests(TestCase):
"""اعتبارسنجی لایهٔ HTTP (قاعدهٔ ۳۰ روز فقط برای بازدید انجام‌شده)."""
def payload(self, **overrides):
base = {'actionType': 'bazdid', 'status': 'finish', 'actionDateTime': at(-1)}
base.update(overrides)
return base
def test_finished_visit_far_in_future_is_rejected(self):
with self.assertRaises(ValidationError):
validate_action_payload(self.payload(actionDateTime=at(+60)))
def test_finished_visit_today_is_accepted(self):
self.assertTrue(validate_action_payload(self.payload(actionDateTime=at(0))))
def test_finished_visit_in_past_is_accepted(self):
self.assertTrue(validate_action_payload(self.payload(actionDateTime=at(-90))))
def test_hamahangi_in_future_is_accepted(self):
self.assertTrue(validate_action_payload(
self.payload(actionType='hamahangi', status='waite', actionDateTime=at(+40))
))
def test_invalid_action_type_is_rejected(self):
with self.assertRaises(ValidationError):
validate_action_payload(self.payload(actionType='انتخاب کنید'))
def test_invalid_status_is_rejected(self):
with self.assertRaises(ValidationError):
validate_action_payload(self.payload(status='done'))
def test_end_of_day_tehran_is_accepted(self):
# ۲۳:۵۹ امروز به وقت تهران (= ۲۰:۲۹ UTC) نباید رد شود.
tz = timezone.get_current_timezone()
today = timezone.localdate()
moment = datetime.datetime.combine(
today, datetime.time(23, 59), tzinfo=tz
)
self.assertTrue(validate_action_payload(self.payload(actionDateTime=moment)))
def test_start_of_day_tehran_is_accepted(self):
# ۰۰:۳۰ امروز تهران در UTC روز قبل است؛ نباید به‌اشتباه رد یا آینده حساب شود.
tz = timezone.get_current_timezone()
today = timezone.localdate()
moment = datetime.datetime.combine(today, datetime.time(0, 30), tzinfo=tz)
self.assertTrue(validate_action_payload(self.payload(actionDateTime=moment)))
def test_naive_payload_is_made_aware(self):
# ورودی naive (بقایای کد قدیمی) باید با تایم‌زون پروژه aware شود.
naive = datetime.datetime.now() - timedelta(days=1)
self.assertTrue(validate_action_payload(self.payload(actionDateTime=naive)))
self.assertFalse(timezone.is_naive(as_aware(naive)))
def run_command(*args):
out = io.StringIO()
call_command('recalc_last_bazdid', *args, stdout=out)
return out.getvalue()
def plan_command(*args):
"""برنامهٔ تغییرات را از همان مسیر ``handle`` می‌گیرد، بدون نوشتن/بکاپ."""
from django.core.management.base import CommandParser
from admin_panel.management.commands.recalc_last_bazdid import Command
command = Command()
parser = CommandParser(add_help=False)
command.add_arguments(parser)
options = parser.parse_args(list(args))
defaults = {'no_color': True, 'force_color': False, 'skip_checks': False,
'traceback': False, 'verbosity': 0, 'pythonpath': None,
'settings': None, 'stdout': io.StringIO(), 'stderr': io.StringIO()}
defaults.update(vars(options))
return command._plan(
defaults['exact'], defaults['clear_orphans'], defaults['batch_size'],
)
class RecalcCommandTests(TestCase):
"""دستور ترمیم: فقط Customer را می‌نویسد، هماهنگی را دست نمی‌زند."""
def setUp(self):
self.forward = make_customer('مشتری رو به جلو')
self.backward = make_customer('مشتری رو به عقب')
self.ham = make_customer('مشتری هماهنگی')
# رو به جلو: مقدار فعلی قدیمی‌تر از واقعیت است.
make_visit(self.forward, action_dt=at(-30), finish_dt=at(-30))
Customer.objects.filter(pk=self.forward.pk).update(last_bazdid_dt=at(-400))
# رو به عقب: مقدار فعلی جلوتر از واقعیت است (drift ویرایش).
make_visit(self.backward, action_dt=at(-100), finish_dt=at(-100))
Customer.objects.filter(pk=self.backward.pk).update(last_bazdid_dt=at(-2))
# هماهنگی: مقدارش دقیقاً از تاریخ هماهنگی است.
self.ham_action = make_visit(
self.ham, action_dt=at(-5), finish_dt=at(-5), action_type='hamahangi',
)
Customer.objects.filter(pk=self.ham.pk).update(last_bazdid_dt=at(-5))
def _values(self):
return dict(Customer.objects.values_list('id', 'last_bazdid_dt'))
def test_dry_run_writes_nothing(self):
before = self._values()
run_command('--exact', '--dry-run')
self.assertEqual(self._values(), before)
def test_exact_requires_confirm(self):
with self.assertRaises(CommandError):
run_command('--exact')
def test_plan_default_mode_ignores_backward_drift(self):
stats, _, pending, _ = plan_command()
self.assertEqual(stats['forward'], 1)
self.assertEqual(stats['backward'], 0)
self.assertNotIn(self.backward.pk, [customer_id for customer_id, _ in pending])
def test_plan_exact_mode_fixes_both_directions(self):
stats, _, pending, _ = plan_command('--exact')
targets = dict(pending)
self.assertEqual(stats['forward'], 1)
self.assertEqual(stats['backward'], 1)
self.assertEqual(as_aware(targets[self.forward.pk]), as_aware(at(-30)))
self.assertEqual(as_aware(targets[self.backward.pk]), as_aware(at(-100)))
def test_apply_writes_only_customer_column(self):
from admin_panel.management.commands.recalc_last_bazdid import Command as Cmd
_, _, pending, _ = plan_command('--exact')
before = action_snapshot(self.ham_action)
Cmd()._apply(pending)
values = self._values()
self.assertEqual(as_aware(values[self.forward.pk]), as_aware(at(-30)))
self.assertEqual(as_aware(values[self.backward.pk]), as_aware(at(-100)))
self.assertEqual(action_snapshot(self.ham_action), before)
def test_plan_is_idempotent_after_apply(self):
from admin_panel.management.commands.recalc_last_bazdid import Command as Cmd
_, _, pending, _ = plan_command('--exact')
Cmd()._apply(pending)
stats, _, pending_again, _ = plan_command('--exact')
self.assertEqual(pending_again, [])
self.assertEqual(stats['no_change'], stats['scanned'])
def test_hamahangi_value_is_untouched_without_clear_orphans(self):
plan_command('--exact')
self.assertEqual(as_aware(self._values()[self.ham.pk]), as_aware(at(-5)))
def test_hamahangi_value_is_cleared_with_clear_orphans(self):
_, _, pending, _ = plan_command('--exact', '--clear-orphans')
self.assertIn((self.ham.pk, None), pending)
def test_hamahangi_suspect_heuristic(self):
_, _, _, suspects = plan_command('--exact')
self.assertEqual(suspects, 1) # فقط مشتری هماهنگی
class EntezarScannerConsistencyTests(TestCase):
"""صفحهٔ «انتظار» و اسکنر پیامک باید یک جواب بدهند."""
def setUp(self):
self.user = make_user('entezarAdmin', superuser=True)
def _entezar_ids(self):
self.client.force_login(self.user)
response = self.client.get('/panel/entezar/')
self.assertEqual(response.status_code, 200)
return {c.id for c in response.context['customers']}
def test_same_customer_state_on_both_sides(self):
from admin_panel.sms.scanner import scan_visit
from admin_panel.sms.timing import DEFAULT_VISIT_MONTHS
months = DEFAULT_VISIT_MONTHS
stale = make_customer('مشتری معوق مشترک')
fresh = make_customer('مشتری تازه بازدیدشده')
Customer.objects.filter(pk=stale.pk).update(
installDate=timezone.localdate() - timedelta(days=400)
)
Customer.objects.filter(pk=fresh.pk).update(
installDate=timezone.localdate() - timedelta(days=400)
)
make_visit(fresh, action_dt=timezone.now() - timedelta(days=5),
finish_dt=timezone.now() - timedelta(days=5))
ids = self._entezar_ids()
self.assertIn(stale.pk, ids)
self.assertNotIn(fresh.pk, ids)
# اسکنر هم باید فقط مشتری معوق را عقب‌افتاده بداند.
from admin_panel.visit_policy import active_deferrals, last_visit_map, visit_period
period = visit_period(months)
now = timezone.now()
visits = last_visit_map(now=now)
deferrals = active_deferrals(now=now)
def is_stale(customer):
anchor = visits.get(customer.pk) or as_aware(customer.installDate)
return (customer.pk not in deferrals) and (anchor + period <= now)
self.assertTrue(is_stale(Customer.objects.get(pk=stale.pk)))
self.assertFalse(is_stale(Customer.objects.get(pk=fresh.pk)))
def test_active_deferral_hides_customer(self):
deferred = make_customer('مشتری تعویق‌دار')
Customer.objects.filter(pk=deferred.pk).update(
installDate=timezone.localdate() - timedelta(days=400)
)
make_visit(deferred, action_dt=at(+3), status='waite',
action_type='hamahangi')
ids = self._entezar_ids()
self.assertNotIn(deferred.pk, ids)
class VisitCadenceTests(TestCase):
"""قاعدهٔ «هر ۹ ماه یک‌بار»: موعد و چرخه از یک تقویم می‌آیند."""
MONTHS = 9
def test_due_date_is_calendar_nine_months(self):
anchor = as_aware(datetime.date(2024, 3, 4))
self.assertEqual(visit_due_date(anchor, months=self.MONTHS).date(),
datetime.date(2024, 12, 4))
def test_cycle_increments_exactly_on_each_due_date(self):
anchor = as_aware(datetime.date(2024, 3, 4))
self.assertEqual(visit_cycle_index(anchor, anchor, months=self.MONTHS), 0)
for k in range(1, 6):
due = visit_due_date(anchor, months=self.MONTHS * k)
# در روز موعدِ k-ام، «دورهٔ جاری» همان k است.
self.assertEqual(visit_cycle_index(anchor, due, months=self.MONTHS), k,
msg=f'چرخه در موعد {k} باید {k} باشد')
def test_cycle_does_not_advance_before_due_date(self):
anchor = as_aware(datetime.date(2024, 3, 4))
due = visit_due_date(anchor, months=self.MONTHS)
before = due - timedelta(days=1)
self.assertEqual(visit_cycle_index(anchor, before, months=self.MONTHS), 0)
self.assertEqual(visit_cycle_index(anchor, due, months=self.MONTHS), 1)
def test_each_period_has_a_distinct_key(self):
"""بازگشت باگ اصلی: کلید هر دوره باید یکتا باشد (نه تکرار، نه پرش)."""
anchor = as_aware(datetime.date(2024, 1, 1))
keys = []
for k in range(1, 7):
due = visit_due_date(anchor, months=self.MONTHS * k)
keys.append((visit_cycle_index(anchor, due, months=self.MONTHS),
due.strftime('%Y-%m')))
self.assertEqual(len({key for _cycle, key in keys}), len(keys))
self.assertEqual([c for c, _k in keys], [1, 2, 3, 4, 5, 6])
def test_customer_without_any_visit_gets_every_period(self):
"""مشتری که هرگز بازدید نشده، برای هر ۹ ماه یک کلید تازه می‌گیرد."""
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
from admin_panel.sms.scanner import scan_visit
customer = make_customer('مشتری بدون بازدید')
PhoneNumber.objects.create(customer=customer, phone_number='09120000123')
Customer.objects.filter(pk=customer.pk).update(
installDate=datetime.date(2023, 1, 1)
)
customer.refresh_from_db()
config = SMSProviderConfig.load()
# «اکنون» این تست در سال ۲۰۲۳–۲۰۲۵ است، پس پنجرهٔ یادآوری هم همان‌جا باز می‌شود.
config.allow_backlog = True
config.backlog_max = 100
config.scan_since = timezone.make_aware(
datetime.datetime(2023, 1, 2, 0, 0), timezone.get_current_timezone(),
)
config.save()
seen_keys = set()
cursor = datetime.date(2023, 1, 2)
while cursor <= datetime.date(2026, 1, 1):
moment = timezone.make_aware(
datetime.datetime.combine(cursor, datetime.time(12, 0)),
timezone.get_current_timezone(),
)
scan_visit(cursor, now=moment)
seen_keys.update(
SMSQueue.objects.filter(customer=customer)
.values_list('dedup_key', flat=True)
)
cursor += timedelta(days=14)
# ۹ ماه اول (۲۰۲۳-۱۰) ، ۱۸ ماه (۲۰۲۴-۰۷) و ۲۷ ماه (۲۰۲۵-۰۴) ⇒ سه دورهٔ یکتا.
# نکته: اسکنر همیشه «تازه‌ترین موعد رسیده» را هدف می‌گیرد؛ پس اگر بین دو اجرا
# بیش از یک دوره گذشته باشد، فقط آخرین دوره صف می‌شود (نه ارسال انبوه).
due_months = {key.rsplit(':', 1)[1] for key in seen_keys}
self.assertEqual(
due_months, {'2023-10', '2024-07', '2025-04'},
msg=f'کلیدهای ساخته‌شده: {sorted(seen_keys)}',
)
class VisitDecisionTests(TestCase):
"""تصمیم‌های محصولی: یک پیامک برای جدیدترین دوره، بازگشت پس از حذف، ریست با نصب."""
MONTHS = 9
def _scan_at(self, day):
from admin_panel.sms.scanner import scan_visit
moment = timezone.make_aware(
datetime.datetime.combine(day, datetime.time(12, 0)),
timezone.get_current_timezone(),
)
return scan_visit(day, now=moment)
def _prep(self, install_date):
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
customer = make_customer('مشتری تصمیم')
PhoneNumber.objects.create(customer=customer, phone_number='09120000777')
# ثبت مستقیم: `installDate` با auto_now_add پر می‌شود، پس با update() بازنویسی می‌کنیم.
Customer.objects.filter(pk=customer.pk).update(installDate=install_date)
customer.refresh_from_db()
self.assertEqual(customer.installDate, install_date)
config = SMSProviderConfig.load()
config.allow_backlog = True
config.backlog_max = 100
config.scan_since = timezone.make_aware(
datetime.datetime(2020, 1, 1, 0, 0), timezone.get_current_timezone(),
)
config.save()
return customer, SMSQueue
# --- تصمیم ۱: فقط یک پیامک برای جدیدترین دوره ---
def test_absent_25_months_gets_exactly_one_reminder(self):
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
self._scan_at(datetime.date(2025, 2, 10)) # ۲۵ ماه بعد
keys = list(SMSQueue.objects.filter(customer=customer).values_list('dedup_key', flat=True))
self.assertEqual(len(keys), 1, msg=f'کلیدها: {keys}')
# جدیدترین دورهٔ رسیده: ۱۸ ماهگی (۲۰۲۴-۰۷) — نه ۹ ماهگی.
self.assertEqual(keys[0].rsplit(':', 1)[1], '2024-07')
# --- تصمیم ۲: حذف یادآوری = کنسل همان دوره تا ۹ ماه ---
def test_deleted_reminder_stays_cancelled_until_next_period(self):
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
self._scan_at(datetime.date(2024, 1, 10)) # دورهٔ ۹ ماهگی
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
# مدیر از پنل صف، پیامک را حذف می‌کند (مسیر واقعی «کنسل دوره»).
from django.urls import reverse
from tests.factories import make_user
manager = make_user('smsQueueManager', superuser=True)
self.client.force_login(manager)
item = SMSQueue.objects.get(customer=customer)
response = self.client.post(
reverse('admin_panel:sms_queue_delete', kwargs={'q_id': item.id}),
)
self.assertEqual(response.status_code, 302)
self.assertFalse(SMSQueue.objects.filter(customer=customer).exists())
# تا پایان دورهٔ اول (موعد بعدی = ۲۰۲۴-۰۷-۰۱) هیچ یادآوری تازه‌ای ساخته نمی‌شود.
for day in (datetime.date(2024, 2, 10), datetime.date(2024, 6, 10)):
self._scan_at(day)
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 0,
msg='حذف باید همان دوره را کنسل نگه دارد')
# دورهٔ دوم (۱۸ ماهگی) ⇒ یادآوری تازه با کلید دیگر.
self._scan_at(datetime.date(2024, 7, 1))
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
self.assertEqual(
SMSQueue.objects.get(customer=customer).dedup_key.rsplit(':', 1)[1], '2024-07',
)
# --- تصمیم ۳: نصب چرخه را ریست می‌کند ---
def test_nasb_action_resets_the_cycle(self):
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
from admin_panel.models import Action
Action.objects.create(
customer=customer, actionType='nasb', status='finish',
actionDateTime=timezone.make_aware(
datetime.datetime(2024, 1, 1, 10, 0), timezone.get_current_timezone(),
),
)
# ۸ ماه بعد از نصب ⇒ هنوز موعد نرسیده
self._scan_at(datetime.date(2024, 9, 1))
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 0)
# ۹ ماه کامل بعد از نصب ⇒ یادآوری
self._scan_at(datetime.date(2024, 10, 5))
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
def test_phone_call_does_not_reset_the_cycle(self):
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
from admin_panel.models import Action
Action.objects.create(
customer=customer, actionType='outCall', status='finish',
actionDateTime=timezone.make_aware(
datetime.datetime(2024, 5, 1, 10, 0), timezone.get_current_timezone(),
),
)
# ۹ ماه از نصب گذشته ⇒ باید معوق بماند، چون تماس تلفنی ریست نمی‌کند
self._scan_at(datetime.date(2024, 10, 5))
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
# --- تصمیم ۴: پایان تعویق ⇒ اگر بازدید قدیمی است، بلافاصله معوق ---
def test_after_deferral_ends_old_visit_makes_customer_due_immediately(self):
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
from admin_panel.models import Action
# آخرین بازدید واقعی: ۱۰ ماه پیش از پایان تعویق (۲۰۲۵-۰۳-۱۵)
Action.objects.create(
customer=customer, actionType='bazdid', status='finish',
actionDateTime=timezone.make_aware(
datetime.datetime(2025, 3, 15, 10, 0), timezone.get_current_timezone(),
),
)
# تعویق فعال تا ۲۰۲۶-۰۱-۱۵ ⇒ در این بازه یادآوری نمی‌گیرد
Action.objects.create(
customer=customer, actionType='hamahangi', status='waite',
actionDateTime=timezone.make_aware(
datetime.datetime(2026, 1, 15, 10, 0), timezone.get_current_timezone(),
),
)
self._scan_at(datetime.date(2026, 1, 10))
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 0)
# تعویق تمام شد؛ ۱۰ ماه از بازدید گذشته ⇒ همان روز یادآوری می‌گیرد
self._scan_at(datetime.date(2026, 1, 20))
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
class NextAppointmentTests(TestCase):
"""تصمیم: هماهنگیِ «انجام‌شده» و بازدیدِ برنامه‌ریزی‌شده هم مشتری را از فهرست خارج می‌کنند."""
def setUp(self):
from tests.factories import make_user
self.user = make_user('apptExpert', superuser=True)
self.client.force_login(self.user)
def _customer(self, name, install_days=400):
customer = make_customer(name)
Customer.objects.filter(pk=customer.pk).update(
installDate=timezone.localdate() - timedelta(days=install_days)
)
customer.refresh_from_db()
return customer
def _entezar_ids(self):
response = self.client.get('/panel/entezar/')
self.assertEqual(response.status_code, 200)
return {c.id for c in response.context['customers']}
def _add(self, customer, action_type, status, when, **extra):
return Action.objects.create(
customer=customer, actionType=action_type, status=status,
actionDateTime=when, **extra,
)
# --- خواستهٔ ۱: هماهنگیِ finish ---
def test_finished_hamahangi_hides_customer_until_appointment(self):
customer = self._customer('هماهنگی انجام‌شده')
# همین حالا تماس گرفتیم و برای هفتهٔ بعد توافق شد؛ هماهنگی «انجام شد».
self._add(customer, 'hamahangi', 'finish', at(+7), finishDateTime=at(0))
self.assertNotIn(customer.pk, self._entezar_ids())
def test_finished_hamahangi_with_past_date_does_not_hide(self):
customer = self._customer('هماهنگی گذشته')
self._add(customer, 'hamahangi', 'finish', at(-2), finishDateTime=at(-2))
self.assertIn(customer.pk, self._entezar_ids())
def test_finished_hamahangi_falls_back_to_finish_datetime(self):
customer = self._customer('هماهنگی بدون تاریخ اقدام')
self._add(customer, 'hamahangi', 'finish', None, finishDateTime=at(+5))
self.assertNotIn(customer.pk, self._entezar_ids())
# --- خواستهٔ ۲: بازدید waite با تاریخ آینده ---
def test_scheduled_visit_hides_customer(self):
customer = self._customer('بازدید برنامه‌ریزی‌شده')
self._add(customer, 'bazdid', 'waite', at(+5))
self.assertNotIn(customer.pk, self._entezar_ids())
def test_scheduled_visit_in_the_past_does_not_hide(self):
customer = self._customer('بازدید عقب‌افتاده')
self._add(customer, 'bazdid', 'waite', at(-5))
self.assertIn(customer.pk, self._entezar_ids())
# --- پس از تاریخ قرار، مشتری برمی‌گردد ---
def test_customer_returns_after_appointment_date(self):
from admin_panel.visit_policy import next_appointment_map
customer = self._customer('قرار منقضی')
self._add(customer, 'bazdid', 'waite', at(+3))
self.assertIn(customer.pk, next_appointment_map(now=at(0)))
self.assertNotIn(customer.pk, next_appointment_map(now=at(+4)))
# --- هماهنگی بازدید نیست ---
def test_hamahangi_never_changes_last_visit(self):
customer = self._customer('هماهنگی بر بازدید بی‌اثر')
self._add(customer, 'hamahangi', 'finish', at(+7), finishDateTime=at(0))
self.assertIsNone(last_visit_dt(customer))
# --- هم‌ترازی صفحه و اسکنر ---
def test_scanner_also_skips_customer_with_appointment(self):
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
from admin_panel.sms.scanner import scan_visit
customer = self._customer('قرار + اسکنر')
PhoneNumber.objects.create(customer=customer, phone_number='09120000999')
config = SMSProviderConfig.load()
config.allow_backlog = True
config.backlog_max = 50
config.scan_since = at(-2000)
config.save()
self._add(customer, 'bazdid', 'waite', at(+5))
now = at(0)
scan_visit(timezone.localdate(), now=now)
self.assertFalse(SMSQueue.objects.filter(customer=customer).exists())
class LastVisitMapEquivalenceTests(TestCase):
"""نسخهٔ SQL ``last_visit_map`` باید دقیقاً همان خروجی نسخهٔ پایتونی را بدهد."""
NOW = datetime.datetime(2026, 6, 15, 12, 0, tzinfo=datetime.timezone.utc)
def setUp(self):
# رکوردهای مرزی که قاعده را به چالش می‌کشند.
cases = [
# (action, finish) هر دو در گذشته ⇒ action ملاک است.
(self.NOW - timedelta(days=30), self.NOW - timedelta(days=31)),
# action در آینده ⇒ finish ملاک است.
(self.NOW + timedelta(days=10), self.NOW - timedelta(days=2)),
# هر دو در آینده ⇒ هیچ تاریخ معتبری ندارد.
(self.NOW + timedelta(days=10), self.NOW + timedelta(days=11)),
# فقط action.
(self.NOW - timedelta(days=5), None),
# فقط finish.
(None, self.NOW - timedelta(days=4)),
# هیچ‌کدام.
(None, None),
# دقیقاً برابر «حالا».
(self.NOW, self.NOW),
]
for index, (action_dt, finish_dt) in enumerate(cases):
customer = make_customer(f'همسانی {index}')
Action.objects.create(
customer=customer, actionType='bazdid', status='finish',
actionDateTime=action_dt, finishDateTime=finish_dt,
)
def test_sql_matches_python_reference(self):
from admin_panel.visit_policy import _last_visit_map_python
py = _last_visit_map_python(now=self.NOW)
sql = last_visit_map(now=self.NOW)
self.assertEqual(py, sql)
def test_sql_matches_reference_for_other_clocks(self):
from admin_panel.visit_policy import _last_visit_map_python
for delta in (timedelta(days=-365), timedelta(days=1), timedelta(days=400)):
now = self.NOW + delta
self.assertEqual(
_last_visit_map_python(now=now), last_visit_map(now=now),
msg=f'clock shift {delta}',
)
def test_timezone_cutoff_is_utc_not_local(self):
"""مرز زمانی نباید با منطقهٔ محلی جابه‌جا شود (باگ ۳:۳۰ ساعتهٔ تهران)."""
customer = make_customer('مرز زمانی')
# رکوردی که ۵ دقیقه بعد از «حالا» است ⇒ نباید حساب شود.
Action.objects.create(
customer=customer, actionType='bazdid', status='finish',
actionDateTime=self.NOW + timedelta(minutes=5),
finishDateTime=self.NOW + timedelta(minutes=5),
)
self.assertNotIn(customer.pk, last_visit_map(now=self.NOW))
class NextAppointmentEquivalenceTests(TestCase):
"""نسخهٔ SQL ``next_appointment_map`` باید همان نگاشت مرجع باشد."""
NOW = datetime.datetime(2026, 6, 15, 12, 0, tzinfo=datetime.timezone.utc)
def test_matches_reference(self):
from admin_panel.visit_policy import next_appointment_map
customer_a = make_customer('قرار الف')
customer_b = make_customer('قرار ب')
customer_c = make_customer('قرار ج')
Action.objects.create(customer=customer_a, actionType='bazdid', status='waite',
actionDateTime=self.NOW + timedelta(days=5))
Action.objects.create(customer=customer_a, actionType='bazdid', status='waite',
actionDateTime=self.NOW + timedelta(days=9))
Action.objects.create(customer=customer_b, actionType='hamahangi', status='finish',
actionDateTime=self.NOW + timedelta(days=3),
finishDateTime=self.NOW)
Action.objects.create(customer=customer_c, actionType='bazdid', status='waite',
actionDateTime=self.NOW - timedelta(days=5))
result = next_appointment_map(now=self.NOW)
self.assertEqual(result[customer_a.pk], self.NOW + timedelta(days=5))
self.assertEqual(result[customer_b.pk], self.NOW + timedelta(days=3))
self.assertNotIn(customer_c.pk, result)
class VisitDedupCleanupTests(TestCase):
"""پاک‌سازی کلیدهای آلودهٔ دفتر یکتاسازی (بدون لمس دادهٔ مشتری)."""
def _keys(self):
from admin_panel.models import SMSDedup
return set(SMSDedup.objects.values_list('key', flat=True))
def test_removes_only_absurd_keys_by_default(self):
from admin_panel.models import SMSDedup
SMSDedup.objects.bulk_create([
SMSDedup(key='visit:10:24663'), # عدد بی‌معنا (باگ تقسیم قدیمی)
SMSDedup(key='visit:11:1'), # قدیمی ولی منطقی
SMSDedup(key='visit:12:2026-03'), # قالب جدید
SMSDedup(key='welcome:5'), # نوع دیگر
])
call_command('cleanup_visit_dedup', stdout=io.StringIO())
self.assertEqual(
self._keys(),
{'visit:11:1', 'visit:12:2026-03', 'welcome:5'},
)
def test_all_legacy_flag_removes_numeric_keys_too(self):
from admin_panel.models import SMSDedup
SMSDedup.objects.bulk_create([
SMSDedup(key='visit:11:1'),
SMSDedup(key='visit:12:2026-03'),
])
call_command('cleanup_visit_dedup', '--all-legacy', stdout=io.StringIO())
self.assertEqual(self._keys(), {'visit:12:2026-03'})
def test_dry_run_deletes_nothing(self):
from admin_panel.models import SMSDedup
SMSDedup.objects.create(key='visit:10:24663')
call_command('cleanup_visit_dedup', '--dry-run', stdout=io.StringIO())
self.assertEqual(self._keys(), {'visit:10:24663'})
def test_customer_and_action_rows_untouched(self):
from admin_panel.models import SMSDedup
customer = make_customer('مشتری پاک‌سازی کلید')
action = make_visit(customer, action_dt=at(-30), finish_dt=at(-30))
before_action = action_snapshot(action)
before_customer = Customer.objects.values().get(pk=customer.pk)
SMSDedup.objects.create(key='visit:1:24663')
call_command('cleanup_visit_dedup', stdout=io.StringIO())
self.assertEqual(action_snapshot(action), before_action)
self.assertEqual(Customer.objects.values().get(pk=customer.pk), before_customer)
self.assertEqual(self._keys(), set())