1339 lines
65 KiB
Python
1339 lines
65 KiB
Python
"""تستهای «آخرین بازدید» (visit_policy) و دستور ترمیم.
|
|
|
|
این ماژول قواعد طلایی زیر را قفل میکند:
|
|
|
|
* فقط اقدام ``bazdid + finish`` در محاسبهٔ آخرین بازدید دیده میشود؛
|
|
«هماهنگی» هرگز.
|
|
* تاریخ مؤثر = ``actionDateTime`` اگر از حالا جلوتر نباشد، وگرنه ``finishDateTime``.
|
|
* بازمحاسبه و ترمیم، هیچ رکورد اقدامی را تغییر نمیدهند.
|
|
* بازدید انجامشده با تاریخ بیش از ۳۰ روز آینده در لایهٔ HTTP رد میشود.
|
|
"""
|
|
import datetime
|
|
import re
|
|
import io
|
|
import os
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
|
|
from django.core.exceptions import ValidationError
|
|
from django.contrib.auth.models import Permission
|
|
from django.core.management import CommandError, call_command
|
|
from django.db import connection
|
|
from django.test import TestCase
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
|
|
from admin_panel.models import Action, Customer, PhoneNumber
|
|
from admin_panel.visit_policy import (active_deferral, as_aware, last_visit_dt,
|
|
last_visit_map, normalize_hold_until,
|
|
refresh_last_bazdid, set_visit_hold,
|
|
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 VisitHoldTests(TestCase):
|
|
"""«قرار بعدی» (تعویق از فرم هماهنگی): هستهٔ رفع باگ + ریسکهای شناساییشده."""
|
|
|
|
def setUp(self):
|
|
from tests.factories import make_user
|
|
|
|
self.user = make_user('holdExpert', superuser=True)
|
|
self.client.force_login(self.user)
|
|
|
|
# ---------- helpers ----------
|
|
def _customer(self, name):
|
|
customer = make_customer(name)
|
|
Customer.objects.filter(pk=customer.pk).update(
|
|
installDate=timezone.localdate() - timedelta(days=400)
|
|
)
|
|
customer.refresh_from_db()
|
|
return customer
|
|
|
|
def _entezar(self, view=''):
|
|
url = '/panel/entezar/' + (f'?view={view}' if view else '')
|
|
response = self.client.get(url)
|
|
self.assertEqual(response.status_code, 200)
|
|
return response
|
|
|
|
def _ids(self, view=''):
|
|
return {c.id for c in self._entezar(view).context['customers']}
|
|
|
|
def _post_action(self, customer, **payload):
|
|
base = {'desc': 'تست', 'userID': self.user.id}
|
|
base.update(payload)
|
|
for key in [k for k, v in list(base.items()) if v is None]:
|
|
base.pop(key)
|
|
return self.client.post(
|
|
reverse('admin_panel:customerNewAction', kwargs={'c_id': customer.pk}), base,
|
|
)
|
|
|
|
def _ms(self, moment):
|
|
return str(int(moment.timestamp() * 1000))
|
|
|
|
# ---------- ریسک ۱: تاریخِ «امروز» تا پایان روز معتبر است ----------
|
|
def test_hold_until_today_hides_customer_until_end_of_day(self):
|
|
customer = self._customer('قرار امروز')
|
|
self._post_action(
|
|
customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now()),
|
|
is_done='on', visitHoldUntil=self._ms(timezone.now()),
|
|
)
|
|
customer.refresh_from_db()
|
|
self.assertIsNotNone(customer.visit_hold_until)
|
|
local = customer.visit_hold_until.astimezone(timezone.get_current_timezone())
|
|
self.assertEqual((local.hour, local.minute), (23, 59))
|
|
self.assertNotIn(customer.pk, self._ids())
|
|
|
|
# ---------- ریسک ۲: finish بدون تاریخ قرار ⇒ پیشفرض ۱۴ روز ----------
|
|
def test_finished_coordination_without_hold_defaults_to_two_weeks(self):
|
|
customer = self._customer('هماهنگی بدون تاریخ قرار')
|
|
self._post_action(
|
|
customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now()),
|
|
is_done='on',
|
|
)
|
|
customer.refresh_from_db()
|
|
self.assertIsNotNone(customer.visit_hold_until)
|
|
days = (customer.visit_hold_until.date() - timezone.localdate()).days
|
|
self.assertEqual(days, 14)
|
|
self.assertNotIn(customer.pk, self._ids())
|
|
|
|
# ---------- ریسک ۳: حذف/لغو هماهنگی ⇒ قرار بیاثر ----------
|
|
def test_hold_becomes_inert_when_coordination_is_deleted(self):
|
|
customer = self._customer('حذف هماهنگی')
|
|
self._post_action(
|
|
customer, actionType='hamahangi',
|
|
dateTimeAnjam=self._ms(timezone.now() + timedelta(days=10)),
|
|
is_done='on',
|
|
visitHoldUntil=self._ms(timezone.now() + timedelta(days=10)),
|
|
)
|
|
self.assertNotIn(customer.pk, self._ids())
|
|
Action.objects.filter(customer=customer).delete()
|
|
customer.refresh_from_db()
|
|
self.assertIsNotNone(customer.visit_hold_until) # ستون باقی است…
|
|
self.assertIn(customer.pk, self._ids()) # …ولی بیاثر شده است
|
|
|
|
def test_hold_becomes_inert_when_coordination_is_cancelled(self):
|
|
customer = self._customer('لغو هماهنگی')
|
|
action = Action.objects.create(
|
|
customer=customer, actionType='hamahangi', status='finish',
|
|
actionDateTime=timezone.now() + timedelta(days=10),
|
|
finishDateTime=timezone.now(),
|
|
)
|
|
set_visit_hold(customer, timezone.now() + timedelta(days=10))
|
|
self.assertNotIn(customer.pk, self._ids())
|
|
Action.objects.filter(pk=action.pk).update(status='cancell')
|
|
self.assertIn(customer.pk, self._ids())
|
|
|
|
# ---------- ریسک ۴: قاعدهٔ «فقط رو به جلو» ----------
|
|
def test_second_coordination_does_not_pull_hold_backwards(self):
|
|
customer = self._customer('قرار قبلی دورتر')
|
|
set_visit_hold(customer, timezone.now() + timedelta(days=30))
|
|
written = set_visit_hold(customer, timezone.now() + timedelta(days=5))
|
|
self.assertFalse(written)
|
|
customer.refresh_from_db()
|
|
self.assertEqual(
|
|
customer.visit_hold_until.date(),
|
|
(timezone.now() + timedelta(days=30)).date(),
|
|
)
|
|
|
|
def test_second_coordination_can_extend_hold(self):
|
|
customer = self._customer('تمدید قرار')
|
|
set_visit_hold(customer, timezone.now() + timedelta(days=5))
|
|
written = set_visit_hold(customer, timezone.now() + timedelta(days=20))
|
|
self.assertTrue(written)
|
|
customer.refresh_from_db()
|
|
self.assertEqual(
|
|
customer.visit_hold_until.date(),
|
|
(timezone.now() + timedelta(days=20)).date(),
|
|
)
|
|
|
|
|
|
# ---------- سقف ۶ ماه و تاریخ گذشته (اعتبارسنجی سرور) ----------
|
|
def test_hold_beyond_six_months_is_rejected(self):
|
|
customer = self._customer('تعویق بلند')
|
|
response = self._post_action(
|
|
customer, actionType='hamahangi', dateTimeAnjam=self._ms(timezone.now()),
|
|
is_done='on', visitHoldUntil=self._ms(timezone.now() + timedelta(days=200)),
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertFalse(Action.objects.filter(customer=customer).exists())
|
|
customer.refresh_from_db()
|
|
self.assertIsNone(customer.visit_hold_until)
|
|
|
|
def test_hold_in_the_past_is_rejected(self):
|
|
with self.assertRaises(ValidationError):
|
|
normalize_hold_until(timezone.now() - timedelta(days=3))
|
|
|
|
def test_hold_none_returns_none(self):
|
|
self.assertIsNone(normalize_hold_until(None))
|
|
|
|
# ---------- بازدید واقعی ⇒ مشتری از فهرست خارج میشود ----------
|
|
def test_real_visit_clears_hold_and_leaves_list(self):
|
|
customer = self._customer('بازدید واقعی')
|
|
Action.objects.create(customer=customer, actionType='hamahangi', status='finish',
|
|
actionDateTime=timezone.now() + timedelta(days=10),
|
|
finishDateTime=timezone.now())
|
|
set_visit_hold(customer, timezone.now() + timedelta(days=10))
|
|
self.assertNotIn(customer.pk, self._ids())
|
|
|
|
self._post_action(
|
|
customer, actionType='bazdid', dateTimeAnjam=self._ms(timezone.now()),
|
|
is_done='on', FinishDateTime=self._ms(timezone.now()), result='انجام شد',
|
|
)
|
|
customer.refresh_from_db()
|
|
self.assertIsNotNone(customer.last_bazdid_dt)
|
|
self.assertNotIn(customer.pk, self._ids('all'))
|
|
|
|
# ---------- هماهنگیِ finish با تاریخ آینده حتی بدون ستون، قرار میسازد ----------
|
|
def test_coordination_without_hold_still_hides_via_action(self):
|
|
customer = self._customer('هماهنگی بدون ستون')
|
|
Action.objects.create(
|
|
customer=customer, actionType='hamahangi', status='finish',
|
|
actionDateTime=timezone.now() + timedelta(days=6),
|
|
finishDateTime=timezone.now(),
|
|
)
|
|
self.assertNotIn(customer.pk, self._ids())
|
|
|
|
|
|
# ---------- تفکیک چهارگروهی و فیلترها ----------
|
|
def test_groups_and_filters(self):
|
|
a = self._customer('الف بدون قرار')
|
|
b = self._customer('ب قرار دستی')
|
|
c = self._customer('ج بازدید برنامه')
|
|
d = self._customer('د هر دو')
|
|
|
|
# b: قرار دستی (هماهنگی با تاریخ تماس «همین حالا» + ستون قرار ۲۰ روز بعد).
|
|
Action.objects.create(customer=b, actionType='hamahangi', status='finish',
|
|
actionDateTime=timezone.now(), finishDateTime=timezone.now())
|
|
set_visit_hold(b, timezone.now() + timedelta(days=20))
|
|
|
|
Action.objects.create(customer=c, actionType='bazdid', status='waite',
|
|
actionDateTime=timezone.now() + timedelta(days=7))
|
|
|
|
# d: هم قرار دستی و هم بازدید برنامهریزیشده.
|
|
Action.objects.create(customer=d, actionType='hamahangi', status='finish',
|
|
actionDateTime=timezone.now(), finishDateTime=timezone.now())
|
|
set_visit_hold(d, timezone.now() + timedelta(days=25))
|
|
Action.objects.create(customer=d, actionType='bazdid', status='waite',
|
|
actionDateTime=timezone.now() + timedelta(days=9))
|
|
|
|
counts = self._entezar().context['counts']
|
|
self.assertEqual(counts['uncoordinated'], 1) # a
|
|
self.assertEqual(counts['manual'], 1) # b
|
|
self.assertEqual(counts['scheduled'], 1) # c
|
|
self.assertEqual(counts['both'], 1) # d
|
|
self.assertEqual(counts['total'], 4)
|
|
self.assertEqual(counts['coordinated'], 3)
|
|
|
|
self.assertEqual(self._ids(), {a.pk})
|
|
self.assertEqual(self._ids('all'), {a.pk, b.pk, c.pk, d.pk})
|
|
self.assertEqual(self._ids('manual'), {b.pk, d.pk})
|
|
self.assertEqual(self._ids('scheduled'), {c.pk, d.pk})
|
|
self.assertEqual(self._ids('coordinated'), {b.pk, c.pk, d.pk})
|
|
|
|
def test_invalid_view_falls_back_to_default(self):
|
|
a = self._customer('فقط الف')
|
|
self.assertEqual(self._ids('nonsense'), {a.pk})
|
|
self.assertEqual(self._entezar('nonsense').context['view'], 'uncoordinated')
|
|
|
|
# ---------- اسکنر پیامک هم قرار دستی را میبیند ----------
|
|
def test_scanner_skips_customer_with_manual_hold(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='09120000555')
|
|
config = SMSProviderConfig.load()
|
|
config.allow_backlog = True
|
|
config.backlog_max = 50
|
|
config.scan_since = timezone.now() - timedelta(days=2000)
|
|
config.save()
|
|
|
|
Action.objects.create(customer=customer, actionType='hamahangi', status='finish',
|
|
actionDateTime=timezone.now() + timedelta(days=15),
|
|
finishDateTime=timezone.now())
|
|
set_visit_hold(customer, timezone.now() + timedelta(days=15))
|
|
|
|
scan_visit(timezone.localdate())
|
|
self.assertFalse(SMSQueue.objects.filter(customer=customer).exists())
|
|
|
|
# ---------- اجتماع منابع ----------
|
|
def test_next_appointment_is_union_of_sources(self):
|
|
from admin_panel.visit_policy import next_appointment_map
|
|
|
|
customer = self._customer('اجتماع منابع')
|
|
now = timezone.now()
|
|
Action.objects.create(customer=customer, actionType='hamahangi', status='finish',
|
|
actionDateTime=now + timedelta(days=30),
|
|
finishDateTime=now)
|
|
set_visit_hold(customer, now + timedelta(days=20))
|
|
merged = next_appointment_map(now=now)
|
|
self.assertIn(customer.pk, merged)
|
|
# قرار دستی «پایان روز» است، پس مقایسه در سطح تاریخ انجام میشود.
|
|
self.assertLessEqual(merged[customer.pk].date(), (now + timedelta(days=20)).date())
|
|
|
|
|
|
class VisitDedupCleanupTests(TestCase):
|
|
"""پاکسازی کلیدهای آلودهٔ دفتر یکتاسازی (بدون لمس دادهٔ مشتری)."""
|
|
|
|
class ActionModalTemplateTests(TestCase):
|
|
"""قرارداد فرم «ایجاد وظیفه» (`customerActionInc.html`).
|
|
|
|
این تستها تضمین میکنند بازطراحی UI، نام فیلدها/شناسههایی را که ویو
|
|
انتظار دارد تغییر ندهد (چون ویو با ``request.POST.get(name)`` میخواند) و
|
|
مودال با همان تقویم شمسی موجود کار کند.
|
|
"""
|
|
|
|
def setUp(self):
|
|
self.user = make_user('modal_viewer')
|
|
self.user.user_permissions.add(*Permission.objects.filter(
|
|
content_type__app_label='admin_panel'))
|
|
self.client.force_login(self.user)
|
|
self.customer = make_customer('مشتری مودال')
|
|
self.html = self.client.get(
|
|
reverse('admin_panel:customerAction', kwargs={'c_id': self.customer.pk})
|
|
).content.decode()
|
|
|
|
def test_expected_form_fields_exist(self):
|
|
# اینها همان کلیدهایی هستند که ویو از request.POST میخواند.
|
|
for name in ('actionType', 'dateTimeAnjam', 'visitHoldUntil', 'desc',
|
|
'userID', 'is_done', 'FinishDateTime', 'result'):
|
|
self.assertIn(f'name="{name}"', self.html, f'فیلد {name} گم شده است')
|
|
|
|
def test_javascript_hooks_exist(self):
|
|
# JS داخل قالب و ویوهای دیگر به این شناسهها و کلاسها وابستهاند.
|
|
for element_id in ('actionType', 'userID', 'checkIsDone', 'sabtBtn05',
|
|
'ActionBox', 'holdHelpToggle', 'holdHelpPanel'):
|
|
self.assertIn(f'id="{element_id}"', self.html, f'شناسه {element_id} گم شده است')
|
|
for cls in ('closeActionBox', 'showWhenDone', 'showWhenCoordination'):
|
|
self.assertIn(cls, self.html, f'کلاس {cls} گم شده است')
|
|
|
|
def test_persian_datepickers_are_used(self):
|
|
for cls in ('dateTimePickerAnjam', 'dateTimePickerFinish', 'dateTimePickerHoldUntil'):
|
|
self.assertIn(cls, self.html, f'تقویم {cls} گم شده است')
|
|
|
|
def test_help_panel_is_collapsed_by_default(self):
|
|
# پنل راهنما باید بسته باشد و با aria-expanded مدیریت شود.
|
|
self.assertIn('aria-expanded="false"', self.html)
|
|
self.assertIn('ax-help-panel is-hidden', self.html)
|
|
self.assertIn('aria-controls="holdHelpPanel"', self.html)
|
|
|
|
def test_help_is_scoped_to_coordination(self):
|
|
# راهنما و فیلد تاریخ پیگیری فقط داخل بلوک «هماهنگی» هستند.
|
|
for marker in ('ax-help-wrap showWhenCoordination is-hidden',
|
|
'ax-field showWhenCoordination is-hidden'):
|
|
self.assertIn(marker, self.html, f'دامنهٔ نمایش {marker} اشتباه است')
|
|
|
|
def test_form_still_posts_to_new_action(self):
|
|
self.assertIn(
|
|
reverse('admin_panel:customerNewAction', kwargs={'c_id': self.customer.pk}),
|
|
self.html,
|
|
)
|
|
|
|
def test_ui_classes_are_namespaced(self):
|
|
# پیشوند ax- جلوی تداخل استایل با سایر صفحات را میگیرد.
|
|
self.assertIn('ax-card', self.html)
|
|
self.assertIn('ax-section', self.html)
|
|
|
|
|
|
class CoordinationDoneConfirmTests(TestCase):
|
|
"""پیشفرض «انجام شده» برای هماهنگی + هشدار و تأیید پیش از ثبت.
|
|
|
|
قواعد قفلشده:
|
|
* فقط «هماهنگی» تیکِ خودکار میگیرد؛ سایر انواع اقدام دستنخوردهاند.
|
|
* پیش از ثبتِ هماهنگیِ انجامشده، تأیید گرفته میشود.
|
|
* در حالت ویرایش و برای انواع دیگر، هیچ تأییدی در مسیر نیست.
|
|
"""
|
|
|
|
def setUp(self):
|
|
self.user = make_user('confirm_viewer')
|
|
self.user.user_permissions.add(*Permission.objects.filter(
|
|
content_type__app_label='admin_panel'))
|
|
self.client.force_login(self.user)
|
|
self.customer = make_customer('مشتری تأیید هماهنگی')
|
|
self.html = self.client.get(
|
|
reverse('admin_panel:customerAction', kwargs={'c_id': self.customer.pk})
|
|
).content.decode()
|
|
|
|
def test_warning_panel_starts_hidden(self):
|
|
self.assertIn('id="holdDoneWarning"', self.html)
|
|
block = self.html.split('id="holdDoneWarning"')[0].rsplit('<div', 1)[-1]
|
|
self.assertIn('is-hidden', block, 'پنل هشدار نباید از ابتدا باز باشد')
|
|
|
|
def test_warning_text_is_present(self):
|
|
self.assertIn('آیا مطمئن هستی که هماهنگی را انجام دادی؟', self.html)
|
|
self.assertIn('اگر هنوز هماهنگی نکردی، این تیک را بردار.', self.html)
|
|
|
|
def test_confirm_buttons_exist(self):
|
|
self.assertIn('id="holdConfirmYes"', self.html)
|
|
self.assertIn('id="holdConfirmNo"', self.html)
|
|
|
|
def test_autocheck_only_for_coordination(self):
|
|
# تیک خودکار باید داخل شاخهٔ hamahangi باشد و برای بقیه برداشته شود.
|
|
block = self.html.split("$(\"select#actionType\").on('change'", 1)[1]
|
|
block = block.split("});", 1)[0]
|
|
self.assertIn("type === 'hamahangi'", block)
|
|
self.assertIn('holdAutoChecked = true', block)
|
|
self.assertIn('holdAutoChecked = false', block)
|
|
|
|
def test_confirmation_is_gated_before_submit(self):
|
|
block = self.html.split('$("#ActionBox").on(\'submit\'', 1)[1]
|
|
block = block.split('});', 1)[0]
|
|
# سه شرط خروج زودهنگام: ویرایش/تأییدشده، نوع غیر هماهنگی، تیک نخورده.
|
|
self.assertIn('isEditMode || holdConfirmed', block)
|
|
self.assertIn("!== 'hamahangi'", block)
|
|
self.assertIn('prop("checked")', block)
|
|
self.assertIn('event.preventDefault()', block)
|
|
|
|
def test_cancelling_confirmation_unchecks(self):
|
|
block = self.html.split('$("#holdConfirmNo").click', 1)[1]
|
|
block = block.split('});', 1)[0]
|
|
self.assertIn('prop("checked", false)', block)
|
|
|
|
def test_edit_mode_skips_confirmation(self):
|
|
# با کلیک روی ردیف موجود، isEditMode روشن میشود ⇒ مسیر تأیید رد میشود.
|
|
self.assertIn('isEditMode = true', self.html)
|
|
self.assertIn('isEditMode = false', self.html)
|
|
|
|
def test_other_action_types_keep_old_done_toggle(self):
|
|
# تیک «انجام شده» همچنان فیلدهای اتمام/نتیجه را نشان میدهد.
|
|
self.assertIn('function syncDoneFields', self.html)
|
|
self.assertIn('showWhenDone', self.html)
|
|
|
|
def test_every_picker_selector_matches_markup(self):
|
|
"""هر کلاسی که در JS برای ساخت تقویم استفاده میشود باید در قالب باشد.
|
|
|
|
این تست جلوی باگ «تقویم باز نمیشود» را میگیرد: اگر نام کلاس در قالب
|
|
با سلکتور جاوااسکریپت یکی نباشد، ``pDatepicker`` روی مجموعهٔ خالی
|
|
اجرا میشود، ``undefined`` برمیگرداند و ``setDate`` با TypeError
|
|
میشکند — که باعث از کار افتادن بقیهٔ منطق مودال هم میشد.
|
|
"""
|
|
selectors = re.findall(r'\$\("\.([\w-]+)"\)\.pDatepicker\(', self.html)
|
|
self.assertTrue(selectors, 'هیچ پیکری در قالب پیدا نشد')
|
|
for selector in selectors:
|
|
self.assertRegex(
|
|
self.html,
|
|
rf'class="[^"]*\b{re.escape(selector)}\b',
|
|
f'تقویم .{selector} در JS هست ولی در قالب هیچ المانی با این کلاس نیست',
|
|
)
|
|
|
|
def test_every_alt_field_class_exists_in_markup(self):
|
|
"""کلاس ``altField`` هر پیکر باید در قالب وجود داشته باشد."""
|
|
alt_fields = re.findall(r"altField:\s*'\.([\w-]+)'", self.html)
|
|
self.assertTrue(alt_fields, 'هیچ altField ای پیدا نشد')
|
|
for selector in alt_fields:
|
|
self.assertRegex(
|
|
self.html,
|
|
rf'class="[^"]*\b{re.escape(selector)}\b',
|
|
f'altField .{selector} در قالب وجود ندارد',
|
|
)
|
|
|
|
def test_hold_picker_calls_are_guarded(self):
|
|
"""فراخوانی setDate پیکر «تاریخ پیگیری» باید فقط از مسیر محافظشده باشد.
|
|
|
|
چون ``pDatepicker`` در صورت عدم تطبیق سلکتور ``undefined`` میدهد،
|
|
فراخوانی مستقیم ``.setDate()`` کل هندلر را میشکند. پس تنها فراخوانی
|
|
مجاز، همان یکی داخل ``resetHoldPicker`` است و بقیهٔ نقاط باید از آن
|
|
استفاده کنند.
|
|
"""
|
|
self.assertIn('function resetHoldPicker', self.html)
|
|
calls = re.findall(r'dateTimePickerHoldUntil\.setDate\(\)', self.html)
|
|
self.assertEqual(len(calls), 1,
|
|
'همهٔ ریستها باید از resetHoldPicker عبور کنند')
|
|
|
|
# آن یک فراخوانی باید داخل تابع محافظ باشد (بعد از بررسی typeof).
|
|
func = self.html.split('function resetHoldPicker', 1)[1].split('\n }', 1)[0]
|
|
self.assertIn('typeof dateTimePickerHoldUntil.setDate === \'function\'', func)
|
|
self.assertIn('dateTimePickerHoldUntil.setDate()', func)
|
|
|
|
# نقاط استفاده باید resetHoldPicker را صدا بزنند.
|
|
self.assertGreaterEqual(self.html.count('resetHoldPicker()'), 3)
|
|
|
|
|
|
class EntezarLayoutTests(TestCase):
|
|
"""ثابتهای چیدمان صفحهٔ انتظار (جلوگیری از رگرسیون UI).
|
|
|
|
این تستها چیزهایی را قفل میکنند که با یک ویرایش ساده میشکنند و در
|
|
تستهای منطقی دیده نمیشوند: تناسب ستونها، حذف ستون «ردیف»، و همترازی
|
|
مرز جدول/کارت.
|
|
"""
|
|
|
|
URL = '/panel/entezar/'
|
|
|
|
def setUp(self):
|
|
self.user = make_user('layout_viewer')
|
|
self.user.user_permissions.add(*Permission.objects.filter(
|
|
content_type__app_label='admin_panel'))
|
|
self.client.force_login(self.user)
|
|
customer = make_customer('مشتری چیدمان')
|
|
Customer.objects.filter(pk=customer.pk).update(
|
|
installDate=(timezone.now() - timedelta(days=900)).date(),
|
|
phone1='09121234567', idCode='C-1',
|
|
)
|
|
make_visit(customer, action_dt=at(-400), finish_dt=at(-400))
|
|
self.html = self.client.get(self.URL, {'view': 'all'}).content.decode()
|
|
|
|
def test_column_widths_sum_to_one_hundred(self):
|
|
widths = [float(w) for w in re.findall(
|
|
r'<col style="width: (\d+(?:\.\d+)?)%">', self.html)]
|
|
self.assertEqual(len(widths), 8, 'تعداد ستونها باید ۸ باشد')
|
|
self.assertAlmostEqual(sum(widths), 100.0, places=1)
|
|
|
|
def test_row_number_is_not_a_column(self):
|
|
# شمارهٔ ردیف به چیپ کنار نام تبدیل شده، نه ستونِ جداگانه.
|
|
self.assertNotIn('<th>ردیف</th>', self.html)
|
|
self.assertIn('ez-idx', self.html)
|
|
|
|
def test_header_cells_match_body_cells(self):
|
|
thead = self.html.split('<tbody>')[0]
|
|
first_row = self.html.split('<tbody>')[1].split('</tr>')[0]
|
|
self.assertEqual(thead.count('<th>'), first_row.count('<td'))
|
|
|
|
def test_no_fixed_min_width_on_columns(self):
|
|
# min-width روی ستونها باعث اسکرول افقی و بیتناسبی میشد.
|
|
self.assertEqual(re.findall(r'<th[^>]*min-width', self.html), [])
|
|
|
|
def test_card_and_table_breakpoints_agree(self):
|
|
# اگر مرز کارت (max-width) و جدول (min-width) یکی نباشند، محدودهای
|
|
# از عرض صفحه هر دو حالت را همزمان میگیرد و چیدمان میشکند.
|
|
cards = re.findall(r'@media \(max-width: (\d+)px\)', self.html)
|
|
tables = [w for w in re.findall(r'@media \(min-width: (\d+)px\)', self.html)
|
|
if w != '1024'] # 1024 مخصوص نوار آمار است
|
|
self.assertTrue(cards, 'بلوک کارت موبایل پیدا نشد')
|
|
self.assertIn(str(int(cards[0]) + 1), tables)
|
|
|
|
def test_phone_renders_left_to_right(self):
|
|
self.assertIn('ez-phone', self.html)
|
|
# شمارهٔ تلفن باید در یک تکه و بدون شکست نمایش داده شود.
|
|
self.assertIn('ez-nowrap', self.html)
|
|
|
|
|
|
class EntezarPageControlsTests(TestCase):
|
|
"""کنترلهای صفحهٔ *انتظار*: فیلتر نما، مرتبسازی، جستوجو و کارتهای آمار.
|
|
|
|
این تستها رفتار کاربری را قفل میکنند: هر عدد کارت باید با تعداد همان
|
|
نما بخواند و جستوجو/مرتبسازی نباید ردیفها را از دست بدهد.
|
|
"""
|
|
|
|
URL = '/panel/entezar/'
|
|
|
|
def setUp(self):
|
|
self.user = make_user('entezar_viewer')
|
|
self.user.user_permissions.add(*Permission.objects.filter(
|
|
content_type__app_label='admin_panel'))
|
|
self.client.force_login(self.user)
|
|
|
|
def _get(self, **params):
|
|
return self.client.get(self.URL, params)
|
|
|
|
def _overdue_customer(self, name, install_days_ago=900, visit_days_ago=None):
|
|
customer = make_customer(name)
|
|
Customer.objects.filter(pk=customer.pk).update(
|
|
installDate=(timezone.now() - timedelta(days=install_days_ago)).date())
|
|
if visit_days_ago is not None:
|
|
make_visit(customer, action_dt=at(-visit_days_ago),
|
|
finish_dt=at(-visit_days_ago))
|
|
return Customer.objects.get(pk=customer.pk)
|
|
|
|
def test_default_view_is_uncoordinated(self):
|
|
response = self._get()
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.context['view'], 'uncoordinated')
|
|
self.assertEqual(response.context['sort'], 'oldest')
|
|
|
|
def test_invalid_view_and_sort_fall_back(self):
|
|
response = self._get(view='nonsense', sort='bogus')
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.context['view'], 'uncoordinated')
|
|
self.assertEqual(response.context['sort'], 'oldest')
|
|
|
|
def test_counts_partition_total(self):
|
|
self._overdue_customer('معوق ساده', visit_days_ago=400)
|
|
counts = self._get().context['counts']
|
|
self.assertEqual(counts['total'],
|
|
counts['uncoordinated'] + counts['coordinated'])
|
|
self.assertEqual(sum(counts['buckets'].values()), counts['uncoordinated'])
|
|
|
|
def test_coordinated_customer_hidden_in_default_view(self):
|
|
customer = self._overdue_customer('با قرار', visit_days_ago=400)
|
|
# قرار فقط با هماهنگیِ فعال معتبر است (قاعدهٔ «ستون ≠ مرجع»).
|
|
make_visit(customer, action_dt=at(-1), action_type='hamahangi', status='waite')
|
|
set_visit_hold(customer, at(+10))
|
|
|
|
default_ids = [obj.id for obj in self._get().context['customers']]
|
|
self.assertNotIn(customer.id, default_ids)
|
|
|
|
all_ids = [obj.id for obj in self._get(view='all').context['customers']]
|
|
self.assertIn(customer.id, all_ids)
|
|
|
|
manual_ids = [obj.id for obj in self._get(view='manual').context['customers']]
|
|
self.assertIn(customer.id, manual_ids)
|
|
|
|
def test_cancelled_coordination_returns_customer_to_list(self):
|
|
customer = self._overdue_customer('هماهنگی لغو شده', visit_days_ago=400)
|
|
coordination = make_visit(customer, action_dt=at(-1),
|
|
action_type='hamahangi', status='waite')
|
|
set_visit_hold(customer, at(+10))
|
|
self.assertNotIn(customer.id,
|
|
[obj.id for obj in self._get().context['customers']])
|
|
|
|
coordination.status = 'cancell'
|
|
coordination.save(update_fields=['status'])
|
|
self.assertIn(customer.id,
|
|
[obj.id for obj in self._get().context['customers']])
|
|
|
|
def test_search_by_name_and_phone(self):
|
|
customer = self._overdue_customer('علی رضایی', visit_days_ago=400)
|
|
PhoneNumber.objects.create(customer=customer, title='تلفن',
|
|
phone_number='09121234567')
|
|
Customer.objects.filter(pk=customer.pk).update(phone1='09121234567')
|
|
|
|
for field, value in (('name', 'رضایی'), ('phone', '09121234567')):
|
|
response = self._get(searchVal=value, searchOn=field)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertIn(customer.id,
|
|
[obj.id for obj in response.context['customers']])
|
|
|
|
def test_sort_overdue_puts_biggest_delay_first(self):
|
|
oldest = self._overdue_customer('خیلی معوق', visit_days_ago=900)
|
|
newer = self._overdue_customer('کمتر معوق', visit_days_ago=300)
|
|
|
|
ids = [obj.id for obj in self._get(view='all', sort='overdue').context['customers']]
|
|
self.assertLess(ids.index(oldest.id), ids.index(newer.id))
|
|
|
|
ids_newest = [obj.id for obj in self._get(view='all', sort='newest').context['customers']]
|
|
self.assertLess(ids_newest.index(newer.id), ids_newest.index(oldest.id))
|