817 lines
38 KiB
Python
817 lines
38 KiB
Python
"""تستهای «آخرین بازدید» (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())
|