improve entezar bazdid
This commit is contained in:
1 parent
1fd27d0445
commit
2aee85593f
18 files changed
+10271
-7558
No files matched your search
@@ -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())
|
||||
Reference in new issue
Block a user