Files
khatibi/tests/test_visit_policy.py
T
2026-10-21 15:13:35 +03:30

2109 lines
106 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""تست‌های «آخرین بازدید» (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.views.customers import ENTEZAR_PAGE_SIZE
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/?view=uncoordinated')
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/?view=uncoordinated')
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='uncoordinated'):
"""پاسخ صفحه؛ پیش‌فرض این کلاس «تعیین تکلیف نشده» است.
(پیش‌فرض خودِ صفحه اکنون «نیازمند پیگیری امروز» است و در
``TodayFollowupTests`` جداگانه پوشش داده می‌شود.)
"""
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='uncoordinated'):
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 _post_edit(self, customer, action, **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:customerEditAction',
kwargs={'c_id': customer.pk, 'action_id': action.pk}),
base,
HTTP_REFERER='/panel/customer/%d/action' % customer.pk,
)
# ---------- ویرایش: تبدیل بازدید به هماهنگی با تاریخ پیگیری ----------
def test_editing_action_into_coordination_saves_hold(self):
"""تبدیل یک اقدام «بازدید» موجود به «هماهنگی» باید تاریخ پیگیری را ذخیره کند."""
customer = self._customer('تبدیل به هماهنگی')
action = Action.objects.create(
customer=customer, actionType='bazdid', status='waite',
actionDateTime=timezone.now() + timedelta(days=5),
desc='بازدید اولیه', forUser=self.user,
)
target = timezone.now() + timedelta(days=20)
response = self._post_edit(
customer, action,
actionType='hamahangi',
dateTimeAnjam=self._ms(timezone.now()),
visitHoldUntil=self._ms(target),
is_done='on',
FinishDateTime=self._ms(timezone.now()),
result='هماهنگ شد',
)
self.assertEqual(response.status_code, 302)
customer.refresh_from_db()
action.refresh_from_db()
self.assertEqual(action.actionType, 'hamahangi')
self.assertIsNotNone(customer.visit_hold_until, 'تاریخ پیگیری ذخیره نشد')
self.assertEqual(customer.visit_hold_until.date(), target.date())
def test_edit_modal_prefills_saved_hold(self):
"""هنگام بازکردن مجدد اقدام، فیلد باید مقدار ذخیره‌شده را نشان دهد.
رگرسیون: مودال مقدار را از دیتابیس نمی‌خواند، پس همیشه خالی دیده می‌شد
(کاربر فکر می‌کرد «ثبت نشده»).
"""
customer = self._customer('نمایش قرار در ویرایش')
Action.objects.create(
customer=customer, actionType='hamahangi', status='finish',
actionDateTime=timezone.now() - timedelta(minutes=5),
finishDateTime=timezone.now() - timedelta(minutes=5),
desc='هماهنگی', forUser=self.user,
)
set_visit_hold(customer, timezone.now() + timedelta(days=18))
html = self.client.get(
reverse('admin_panel:customerAction', kwargs={'c_id': customer.pk})
).content.decode()
# ردیف‌ها مقدار ذخیره‌شده را به‌صورت ISO در خود دارند...
self.assertIn('customer_hold=', html)
self.assertIn('function seedHoldPicker', html)
self.assertIn('seedHoldPicker(customerHold)', html)
def test_editing_old_visit_does_not_wipe_newer_hold(self):
"""ویرایش یک اقدام قدیمی نباید قرار هماهنگیِ جدیدتر را پاک کند."""
customer = self._customer('ویرایش قدیمی')
old_visit = Action.objects.create(
customer=customer, actionType='bazdid', status='finish',
actionDateTime=timezone.now() - timedelta(days=400),
finishDateTime=timezone.now() - timedelta(days=400),
desc='بازدید قدیمی', forUser=self.user,
)
Action.objects.create(
customer=customer, actionType='hamahangi', status='finish',
actionDateTime=timezone.now() - timedelta(minutes=5),
finishDateTime=timezone.now() - timedelta(minutes=5),
desc='هماهنگی جدید', forUser=self.user,
)
set_visit_hold(customer, timezone.now() + timedelta(days=20))
# فقط توضیح اقدام قدیمی را عوض می‌کنیم
self._post_edit(
customer, old_visit,
actionType='bazdid',
dateTimeAnjam=self._ms(timezone.now() - timedelta(days=400)),
is_done='on',
FinishDateTime=self._ms(timezone.now() - timedelta(days=400)),
desc='توضیح اصلاح شد',
)
customer.refresh_from_db()
self.assertIsNotNone(customer.visit_hold_until,
'ویرایش اقدام قدیمی قرار معتبر را پاک کرد')
self.assertIn(customer.pk, self._ids('manual'))
# ---------- ریسک ۱: تاریخِ «امروز» تا پایان روز معتبر است ----------
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_visit_created_after_coordination_clears_manual_hold(self):
"""سناریوی گزارش‌شده: هماهنگی + قرار، سپس ثبت «بازدید».
انتظار: مشتری از `view=manual` خارج شود و در `view=scheduled` دیده شود.
"""
customer = self._customer('بازدید بعد از هماهنگی')
Action.objects.create(
customer=customer, actionType='hamahangi', status='finish',
# «چند دقیقه پیش تماس گرفتم» — از مرز لحظه‌ای فاصله دارد.
actionDateTime=timezone.now() - timedelta(minutes=5),
finishDateTime=timezone.now() - timedelta(minutes=5),
)
set_visit_hold(customer, timezone.now() + timedelta(days=2))
self.assertIn(customer.pk, self._ids('manual'))
# بازدید برنامه‌ریزی‌شده (بدون تیک «انجام شد»).
self._post_action(
customer, actionType='bazdid',
dateTimeAnjam=self._ms(timezone.now() + timedelta(hours=2)),
desc='بازدید برنامه‌ریزی شد',
)
customer.refresh_from_db()
self.assertIsNone(customer.visit_hold_until, 'قرار دستی باید پاک شود')
self.assertNotIn(customer.pk, self._ids('manual'))
self.assertIn(customer.pk, self._ids('scheduled'))
def test_visit_created_after_coordination_without_hold_still_schedules(self):
customer = self._customer('بدون قرار قبلی')
self._post_action(
customer, actionType='bazdid',
dateTimeAnjam=self._ms(timezone.now() + timedelta(days=3)),
desc='بازدید',
)
self.assertIn(customer.pk, self._ids('scheduled'))
def test_old_visit_does_not_void_newer_coordination(self):
"""بازدید قدیمی‌تر نباید قرار هماهنگیِ جدیدتر را باطل کند."""
customer = self._customer('هماهنگی جدیدتر')
Action.objects.create(
customer=customer, actionType='bazdid', status='finish',
actionDateTime=timezone.now() - timedelta(days=400),
finishDateTime=timezone.now() - timedelta(days=400),
)
Action.objects.create(
customer=customer, actionType='hamahangi', status='finish',
actionDateTime=timezone.now() - timedelta(minutes=5),
finishDateTime=timezone.now() - timedelta(minutes=5),
)
set_visit_hold(customer, timezone.now() + timedelta(days=20))
self.assertIn(customer.pk, self._ids('manual'))
def test_scheduled_visit_earlier_today_still_counts(self):
"""بازدیدِ برنامه‌ریزی‌شده برای «امروز» تا پایان امروز معتبر است.
رگرسیون: فیلتر قبلی لحظه‌ای بود و بازدیدی که ساعتش چند دقیقه پیش گذشته
بود از فهرست ناپدید می‌شد.
"""
customer = self._customer('بازدید امروز')
Action.objects.create(
customer=customer, actionType='bazdid', status='waite',
actionDateTime=timezone.now() - timedelta(minutes=30),
)
self.assertIn(customer.pk, self._ids('scheduled'))
def test_scheduled_visit_yesterday_is_not_upcoming(self):
customer = self._customer('بازدید دیروز')
Action.objects.create(
customer=customer, actionType='bazdid', status='waite',
actionDateTime=timezone.now() - timedelta(days=1),
)
self.assertNotIn(customer.pk, self._ids('scheduled'))
# ---------- تفکیک چهارگروهی و فیلترها ----------
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() - timedelta(minutes=5),
finishDateTime=timezone.now() - timedelta(minutes=5))
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: بازدید برنامه‌ریزی‌شده (قدیمی‌تر) + قرار دستی (جدیدتر).
# ترتیب مهم است: اگر بازدید *پس از* هماهنگی ثبت شود، هماهنگی منتفی
# محسوب می‌شود (تست جداگانهٔ زیر). اینجا برعکس است تا گروه D پوشش داده شود.
Action.objects.create(customer=d, actionType='bazdid', status='waite',
actionDateTime=timezone.now() + timedelta(days=9))
Action.objects.create(customer=d, actionType='hamahangi', status='finish',
actionDateTime=timezone.now() - timedelta(minutes=5),
finishDateTime=timezone.now() - timedelta(minutes=5))
set_visit_hold(d, timezone.now() + timedelta(days=25))
counts = self._entezar().context['counts']
self.assertEqual(counts['uncoordinated'], 1) # a
# هر کارت فقط منبع خودش را می‌شمارد؛ d هر دو منبع را دارد ⇒ در هر سه
# کارت شمرده می‌شود (قرار دستی، بازدید، با قرار).
self.assertEqual(counts['manual'], 2) # b + d
self.assertEqual(counts['scheduled'], 2) # c + d
self.assertEqual(counts['coordination'], 0) # هیچ‌کدام هماهنگیِ فعال ندارد
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('coordination'), set())
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._entezar('nonsense').context['view'], 'today')
self.assertEqual(self._ids('nonsense'), set())
self.assertIn(a.pk, self._ids('uncoordinated'))
def test_card_number_matches_filtered_list(self):
"""عدد هر کارت باید با تعداد ردیف‌های همان فیلتر یکی باشد.
رگرسیون: کارت «قرار هماهنگی» فقط گروه B را می‌شمرد، ولی فیلتر
``view=manual`` گروه D را هم نشان می‌داد؛ کاربر عدد ۰ می‌دید و با کلیک
ردیف پیدا می‌کرد.
"""
b = self._customer('قرار دستی تنها')
Action.objects.create(customer=b, actionType='hamahangi', status='finish',
actionDateTime=timezone.now() - timedelta(minutes=5),
finishDateTime=timezone.now() - timedelta(minutes=5))
set_visit_hold(b, timezone.now() + timedelta(days=20))
d = self._customer('هم قرار و هم بازدید')
Action.objects.create(customer=d, actionType='bazdid', status='waite',
actionDateTime=timezone.now() + timedelta(days=9))
Action.objects.create(customer=d, actionType='hamahangi', status='finish',
actionDateTime=timezone.now() - timedelta(minutes=5),
finishDateTime=timezone.now() - timedelta(minutes=5))
set_visit_hold(d, timezone.now() + timedelta(days=25))
counts = self._entezar().context['counts']
self.assertEqual(counts['manual'], len(self._ids('manual')))
self.assertEqual(counts['scheduled'], len(self._ids('scheduled')))
self.assertEqual(counts['coordination'], len(self._ids('coordination')))
self.assertEqual(counts['coordinated'], len(self._ids('coordinated')))
self.assertEqual(counts['uncoordinated'], len(self._ids('uncoordinated')))
self.assertEqual(counts['total'], len(self._ids('all')))
def test_card_number_matches_list_for_every_new_view(self):
"""هر سه منبع، فیلتر مستقل خودش را دارد و عدد کارت با فهرست می‌خواند."""
# فقط هماهنگی (بدون بازدید) — موردِ باگ گزارش‌شده
coord = self._customer('فقط هماهنگی')
Action.objects.create(customer=coord, actionType='hamahangi', status='finish',
actionDateTime=timezone.now() + timedelta(days=8),
finishDateTime=timezone.now() - timedelta(minutes=5))
# فقط بازدید
sched = self._customer('فقط بازدید')
Action.objects.create(customer=sched, actionType='bazdid', status='waite',
actionDateTime=timezone.now() + timedelta(days=5))
# هر دو
both = self._customer('بازدید و هماهنگی')
Action.objects.create(customer=both, actionType='bazdid', status='waite',
actionDateTime=timezone.now() + timedelta(days=6))
Action.objects.create(customer=both, actionType='hamahangi', status='finish',
actionDateTime=timezone.now() + timedelta(days=9),
finishDateTime=timezone.now() - timedelta(minutes=5))
counts = self._entezar().context['counts']
# «هماهنگی» هرگز در نمای «بازدید برنامه‌ریزی‌شده» ظاهر نمی‌شود.
self.assertNotIn(coord.pk, self._ids('scheduled'))
self.assertIn(coord.pk, self._ids('coordination'))
# و برعکس.
self.assertNotIn(sched.pk, self._ids('coordination'))
self.assertIn(sched.pk, self._ids('scheduled'))
# مشتریِ هر دو منبع، در هر دو نما هست.
self.assertIn(both.pk, self._ids('scheduled'))
self.assertIn(both.pk, self._ids('coordination'))
# اما در «با قرار» فقط یک‌بار شمرده می‌شود.
self.assertIn(both.pk, self._ids('coordinated'))
for key in ('manual', 'scheduled', 'coordination', 'coordinated',
'uncoordinated', 'total'):
self.assertEqual(counts[key], len(self._ids(
'all' if key == 'total' else key)), f'عدد کارت {key} با فهرست نمی‌خواند')
def test_stats_cards_have_minimal_descriptions(self):
"""هر کارت آمار باید یک توضیح کوتاه داشته باشد تا معنی‌اش روشن باشد."""
html = self._entezar().content.decode()
# سرِ نوار آمار + توضیح «عقب‌افتاده»
self.assertIn('ez-stats-title', html)
self.assertIn('ez-stats-hint', html)
# چهار کارت: پیگیری امروز + سه کارت وضعیت
self.assertEqual(html.count('class="ez-desc"'), 4)
for phrase in ('موعد تماسشان امروز است',
'با هر وضعیت پیگیری',
'هنوز وقت بازدید تعیین نشده',
'وقت بعدی‌شان مشخص است'):
self.assertIn(phrase, html, f'توضیح «{phrase}» روی کارت‌ها نیست')
def test_card_titles_use_business_language(self):
"""نام کارت‌ها باید زبان کسب‌وکار باشد، نه زبان دیتابیس."""
html = self._entezar().content.decode()
self.assertIn('همهٔ مشتریان عقب‌افتاده', html)
self.assertIn('تعیین تکلیف نشده', html)
self.assertIn('تعیین تکلیف شده', html)
# نام‌های قدیمیِ گیج‌کننده نباید برگردند.
self.assertNotIn('کل عقب‌افتاده‌ها', html)
self.assertNotIn('قرار هماهنگی', html)
# «بازدید» به‌جای «بازدیدها» — عدد، تعداد مشتری است نه بازدید.
self.assertNotIn('کل بازدیدهای عقب افتاده', html)
def test_extra_cards_moved_into_type_filter(self):
"""سه کارت حذف‌شده باید فقط در دراپ‌داون «نوع قرار» باشند، نه روی کارت‌ها."""
html = self._entezar().content.decode()
# چهار کارت آمار: «نیازمند پیگیری امروز» + سه کارت وضعیت
self.assertEqual(html.count('class="ez-stat '), 4)
self.assertIn('نیازمند پیگیری امروز', html)
# هر سه فیلتر در دراپ‌داون موجودند
self.assertIn('name="view"', html)
for value in ('manual', 'scheduled', 'coordination'):
self.assertIn(f'value="{value}"', html)
def test_cards_have_no_breakdown_or_warning(self):
"""کارت‌ها باید ساده بمانند: هیچ تفکیک «تعویق/بازدید/پیگیری» رویشان نباشد."""
html = self._entezar().content.decode()
self.assertNotIn('ez-split', html)
self.assertNotIn('ez-alert', html)
cards = html.split('class="ez-filters"')[0]
# چهار عدد روی کارت‌ها (نه پنج/شش عدد قبلیِ تفکیک)
self.assertEqual(cards.count('class="ez-num'), 4)
# «تعویق» فقط در تفکیکِ حذف‌شده بود؛ نباید هیچ‌جای کارت‌ها بماند.
self.assertNotIn('تعویق', cards)
# هیچ شمارنده‌ای کنار «بازدید» یا «پیگیری» نباید باشد
# (الگوی قبلی: «۱۲ بازدید» / «۳۰۶ پیگیری»).
for word in ('بازدید', 'پیگیری'):
self.assertNotRegex(
cards, r'\d+\s*' + word,
msg=f'شمارندهٔ کنار «{word}» نباید روی کارت‌ها باشد')
# ---------- اسکنر پیامک هم قرار دستی را می‌بیند ----------
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), 9, 'تعداد ستون‌ها باید ۹ باشد')
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]
# سرستون‌ها اکنون لینک مرتب‌سازی دارند، پس کلاس می‌گیرند نه <th>ِ ساده.
# (``<thead`` هم با «<th» شروع می‌شود، پس با regex دقیق می‌شماریم.)
self.assertEqual(len(re.findall(r'<th[\s>]', thead)),
first_row.count('<td'))
self.assertEqual(len(re.findall(r'<th[\s>]', thead)), 9)
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_today(self):
"""پیش‌فرض صفحه = «نیازمند پیگیری امروز»."""
response = self._get()
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['view'], 'today')
self.assertTrue(response.context['is_followup_view'])
# ترتیب پیش‌فرض نمای پیگیری، زودترین موعد است (نه قدیمی‌ترین بازدید).
self.assertEqual(response.context['sort'], 'last_visit')
self.assertEqual(response.context['sort_dir'], 'asc')
def test_invalid_view_and_sort_fall_back(self):
response = self._get(view='nonsense', sort='bogus', delay='bogus')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['view'], 'today')
self.assertEqual(response.context['sort'], 'last_visit')
self.assertEqual(response.context['sort_dir'], 'asc')
self.assertEqual(response.context['delay'], '')
def test_counts_partition_total(self):
self._overdue_customer('معوق ساده', visit_days_ago=400)
counts = self._get(view='uncoordinated').context['counts']
self.assertEqual(counts['total'],
counts['uncoordinated'] + counts['coordinated'])
self.assertEqual(sum(counts['buckets'].values()), counts['uncoordinated'])
def test_coordinated_customer_hidden_in_uncoordinated_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))
uncoordinated_ids = [
obj.id for obj in self._get(view='uncoordinated').context['customers']]
self.assertNotIn(customer.id, uncoordinated_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(view='uncoordinated').context['customers']])
coordination.status = 'cancell'
coordination.save(update_fields=['status'])
self.assertIn(customer.id, [
obj.id for obj in self._get(view='uncoordinated').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(view='uncoordinated', searchVal=value, searchOn=field)
self.assertEqual(response.status_code, 200)
self.assertIn(customer.id,
[obj.id for obj in response.context['customers']])
# ---------- فیلتر «میزان تأخیر» ----------
def test_delay_filter_selects_exact_range(self):
"""هر بازهٔ تأخیر فقط مشتریان همان بازه را برمی‌گرداند."""
# تأخیر = امروز − (آخرین بازدید + دورهٔ ۹ ماهه).
# بازدید ۲۸۰ روز پیش ⇒ تأخیر ~۶ روز (بازهٔ lt30)
# بازدید ۷۰۰ روز پیش ⇒ تأخیر ~۴۲۶ روز (بازهٔ gt180)
fresh = self._overdue_customer('تازه معوق', visit_days_ago=280)
old = self._overdue_customer('کهنه معوق', visit_days_ago=700)
ids_lt30 = [o.id for o in self._get(view='all', delay='lt30').context['customers']]
self.assertIn(fresh.id, ids_lt30)
self.assertNotIn(old.id, ids_lt30)
ids_gt180 = [o.id for o in self._get(view='all', delay='gt180').context['customers']]
self.assertNotIn(fresh.id, ids_gt180)
self.assertIn(old.id, ids_gt180)
def test_delay_buckets_sum_to_view_total(self):
self._overdue_customer('الف', visit_days_ago=280)
self._overdue_customer('ب', visit_days_ago=430)
for view in ('all', 'uncoordinated'):
counts = self._get(view=view).context['counts']
self.assertEqual(sum(counts['buckets'].values()), counts['view_total'],
f'جمع بازه‌های تأخیر با نمای {view} نمی‌خواند')
def test_delay_filter_does_not_change_card_counts(self):
"""کارت‌ها همیشه کل جامعه را نشان می‌دهند، نه نتیجهٔ فیلتر را."""
self._overdue_customer('الف', visit_days_ago=280)
base = self._get().context['counts']
filtered = self._get(delay='gt180').context['counts']
self.assertEqual(base['total'], filtered['total'])
self.assertEqual(base['uncoordinated'], filtered['uncoordinated'])
self.assertLessEqual(filtered['selected'], base['selected'])
# ---------- مرتب‌سازی ستون‌ها ----------
def test_every_column_is_sortable_both_directions(self):
self._overdue_customer('الف', visit_days_ago=400)
self._overdue_customer('ب', visit_days_ago=500)
for column in ('name', 'status', 'manual', 'scheduled', 'coordination',
'last_visit', 'overdue', 'phone', 'address'):
for direction in ('asc', 'desc'):
response = self._get(view='all', sort=column, dir=direction)
self.assertEqual(response.status_code, 200,
f'ستون {column}/{direction} خطا داد')
self.assertEqual(response.context['sort'], column)
self.assertEqual(response.context['sort_dir'], direction)
def test_explicit_direction_overrides_legacy_alias(self):
"""``sort=overdue&dir=asc`` باید صعودی بماند، نه به desc قدیمی بیفتد."""
self._overdue_customer('الف', visit_days_ago=400)
response = self._get(view='all', sort='overdue', dir='asc')
self.assertEqual(response.context['sort'], 'overdue')
self.assertEqual(response.context['sort_dir'], 'asc')
def test_name_sort_orders_alphabetically(self):
first = self._overdue_customer('آ ا', visit_days_ago=400)
second = self._overdue_customer('ی ی', visit_days_ago=400)
ids = [o.id for o in self._get(view='all', sort='name', dir='asc').context['customers']]
self.assertLess(ids.index(first.id), ids.index(second.id))
ids_desc = [o.id for o in self._get(view='all', sort='name', dir='desc').context['customers']]
self.assertLess(ids_desc.index(second.id), ids_desc.index(first.id))
def test_missing_appointment_dates_sort_last(self):
"""مشتریِ بدون قرار دستی باید در هر دو جهت، آخرِ فهرست بیاید."""
with_hold = self._overdue_customer('با قرار', visit_days_ago=400)
make_visit(with_hold, action_dt=at(-1), action_type='hamahangi', status='waite')
set_visit_hold(with_hold, at(+10))
without = self._overdue_customer('بدون قرار', visit_days_ago=400)
for direction in ('asc', 'desc'):
ids = [o.id for o in self._get(view='all', sort='manual',
dir=direction).context['customers']]
self.assertLess(ids.index(with_hold.id), ids.index(without.id),
f'در جهت {direction} مشتریِ بدون قرار اول آمد')
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))
# ---------- سرستون‌ها و حفظ فیلترها ----------
def test_sortable_headers_render_with_arrows(self):
html = self._get(view='all', sort='name', dir='desc').content.decode()
self.assertEqual(html.count('class="ez-th '), 9)
# فقط یک سرستون فعال است (کلاس دقیق، نه تکرار در CSS).
self.assertEqual(html.count('class="ez-th is-sorted"'), 1)
self.assertIn('ez-arrow', html)
self.assertIn('▼', html) # جهت نزولیِ فعال
self.assertIn('⇅', html) # ستون‌های غیرفعال
def test_header_link_toggles_direction(self):
response = self._get(view='all', sort='name', dir='asc')
header = {h['key']: h for h in response.context['sort_header']}['name']
self.assertTrue(header['active'])
self.assertEqual(header['dir'], 'asc')
# لینک همان ستون باید جهت را برگرداند.
self.assertIn('dir=desc', header['link'])
def test_filter_links_preserve_other_filters(self):
response = self._get(view='all', delay='gt180', sort='name', dir='asc')
ctx = response.context
for link in ctx['view_links'].values():
self.assertIn('delay=gt180', link)
self.assertIn('sort=name', link)
for link in ctx['delay_links'].values():
self.assertIn('sort=name', link)
for link in ctx['sort_links'].values():
self.assertIn('delay=gt180', link)
def test_clear_link_resets_everything(self):
response = self._get(view='all', delay='gt180', sort='name', dir='asc',
searchVal='x', searchOn='name')
self.assertEqual(response.context['clear_link'], '?view=today')
self.assertTrue(response.context['has_filters'])
def test_no_filters_means_clean_state(self):
self.assertFalse(self._get().context['has_filters'])
def test_pagination_keeps_filters(self):
# یک ردیف بیشتر از اندازهٔ صفحه تا صفحهٔ دوم ساخته شود.
for index in range(ENTEZAR_PAGE_SIZE + 1):
self._overdue_customer(f'مشتری شماره {index}', visit_days_ago=700)
response = self._get(view='all', delay='gt180', sort='name', dir='asc')
self.assertTrue(response.context['page_next'])
self.assertIn('delay=gt180', response.context['page_next'])
self.assertIn('page=2', response.context['page_next'])
self.assertIn('sort=name', response.context['page_next'])
self.assertEqual(response.context['page_prev'], '')
class TodayFollowupTests(TestCase):
"""نمای «نیازمند پیگیری امروز» در صفحهٔ انتظار.
قواعد قفل‌شده:
* فقط موعدهای **همان روز** (پیش‌فرض: امروز) نمایش داده می‌شوند.
* مشتریانی که برایشان «بازدید» گذاشته شده **هرگز** در این فهرست نمی‌آیند.
* با تقویم شمسی می‌توان روزهای دیگر (گذشته/آینده) را دید.
* عدد کارت با تعداد فهرست امروز برابر است و با تغییر تاریخ عوض نمی‌شود.
"""
URL = '/panel/entezar/'
TODAY = timezone.localdate()
def setUp(self):
self.user = make_user('followupViewer', superuser=True)
self.client.force_login(self.user)
# ---------- helpers ----------
def _customer(self, name, *, active=True):
customer = make_customer(name)
Customer.objects.filter(pk=customer.pk).update(
installDate=self.TODAY - timedelta(days=400), is_active=active,
)
customer.refresh_from_db()
return customer
def _coordination(self, customer, day_offset=0):
"""اقدام هماهنگی با موعد پیگیری در روز مشخص (نسبت به امروز)."""
noon = timezone.make_aware(
datetime.datetime.combine(
self.TODAY + timedelta(days=day_offset), datetime.time(12, 0)
),
timezone.get_current_timezone(),
)
return make_visit(customer, action_dt=noon, action_type='hamahangi',
status='finish')
def _visit(self, customer, day_offset=0):
"""اقدام بازدیدِ در انتظار (قرار بازدید)."""
noon = timezone.make_aware(
datetime.datetime.combine(
self.TODAY + timedelta(days=day_offset), datetime.time(10, 0)
),
timezone.get_current_timezone(),
)
return make_visit(customer, action_dt=noon, action_type='bazdid',
status='waite')
def _jalali(self, day_offset=0):
from persiantools.jdatetime import JalaliDate
return JalaliDate(self.TODAY + timedelta(days=day_offset)).strftime('%Y/%m/%d')
def _ids(self, query=''):
response = self.client.get(self.URL + '?view=today' + query)
self.assertEqual(response.status_code, 200)
return {obj.id for obj in response.context['customers']}
# ---------- هستهٔ رفتار ----------
def test_followup_today_appears(self):
customer = self._customer('پیگیری امروز')
self._coordination(customer, 0)
self.assertIn(customer.id, self._ids())
def test_followup_other_days_not_in_today(self):
yesterday = self._customer('دیروز')
tomorrow = self._customer('فردا')
self._coordination(yesterday, -1)
self._coordination(tomorrow, +1)
ids = self._ids()
self.assertNotIn(yesterday.id, ids)
self.assertNotIn(tomorrow.id, ids)
def test_calendar_navigates_to_other_days(self):
yesterday = self._customer('دیروز')
tomorrow = self._customer('فردا')
self._coordination(yesterday, -1)
self._coordination(tomorrow, +1)
past = self._ids('&fdate=' + self._jalali(-1))
future = self._ids('&fdate=' + self._jalali(+1))
self.assertIn(yesterday.id, past)
self.assertNotIn(tomorrow.id, past)
self.assertIn(tomorrow.id, future)
self.assertNotIn(yesterday.id, future)
def test_invalid_followup_date_falls_back_to_today(self):
customer = self._customer('امروز')
self._coordination(customer, 0)
self.assertIn(customer.id, self._ids('&fdate=نامعتبر'))
def test_customer_without_any_followup_never_appears(self):
customer = self._customer('بدون موعد')
self.assertNotIn(customer.id, self._ids())
# ---------- قاعدهٔ قطعی: «بازدید» هرگز در این فهرست نمی‌آید ----------
def test_visit_today_excludes_customer(self):
"""بازدید برای همان روز ⇒ مشتری نباید در فهرست پیگیری باشد."""
customer = self._customer('بازدید امروز')
self._coordination(customer, 0)
self.assertIn(customer.id, self._ids())
self._visit(customer, 0)
self.assertNotIn(
customer.id, self._ids(),
msg='مشتریِ دارای بازدیدِ امروز نباید در فهرست پیگیری بیاید',
)
def test_visit_in_future_excludes_customer(self):
"""بازدیدی که برای آینده گذاشته شده هم مشتری را از فهرست بیرون می‌برد."""
customer = self._customer('بازدید آینده')
self._coordination(customer, 0)
self._visit(customer, +5)
self.assertNotIn(customer.id, self._ids())
def test_visit_in_past_does_not_exclude(self):
"""بازدیدِ کهنه (backlog بسته‌نشده) قرار فعال نیست ⇒ نباید پنهان کند."""
customer = self._customer('بازدید کهنه')
self._coordination(customer, 0)
self._visit(customer, -30)
self.assertIn(customer.id, self._ids())
def test_scheduled_view_still_works(self):
"""قاعدهٔ حذف نباید نمای «بازدید برنامه‌ریزی‌شده» را خالی کند."""
customer = self._customer('بازدید برنامه')
self._visit(customer, +3)
response = self.client.get(self.URL + '?view=scheduled')
self.assertIn(customer.id, {o.id for o in response.context['customers']})
# ---------- قرار دستی روی ستون مشتری ----------
def test_manual_hold_today_appears(self):
customer = self._customer('قرار دستی امروز')
self._coordination(customer, -3)
# قرار دستی روی پایان همان روزِ امروز
noon_today = timezone.make_aware(
datetime.datetime.combine(self.TODAY, datetime.time(12, 0)),
timezone.get_current_timezone(),
)
set_visit_hold(customer, noon_today)
self.assertIn(customer.id, self._ids())
# ---------- شمارنده‌ها و نماها ----------
def test_card_count_matches_list(self):
first = self._customer('الف')
second = self._customer('ب')
self._coordination(first, 0)
self._coordination(second, 0)
response = self.client.get(self.URL)
self.assertEqual(response.context['counts']['followup_today'],
response.context['counts']['followup_today'])
listed = self.client.get(self.URL + '?view=today').context
self.assertEqual(listed['counts']['followup_today'],
listed['customers'].paginator.count)
def test_card_count_stays_today_when_browsing_other_day(self):
customer = self._customer('فردا')
self._coordination(customer, +1)
today_ctx = self.client.get(self.URL).context
past_ctx = self.client.get(
self.URL + '?view=today&fdate=' + self._jalali(-1)).context
self.assertEqual(today_ctx['counts']['followup_today'],
past_ctx['counts']['followup_today'])
def test_default_view_is_today(self):
response = self.client.get(self.URL)
self.assertEqual(response.context['view'], 'today')
self.assertTrue(response.context['is_followup_view'])
def test_inactive_and_cancelled_customers_excluded(self):
inactive = self._customer('غیرفعال', active=False)
cancelled = self._customer('لغو بازدید')
self._coordination(inactive, 0)
self._coordination(cancelled, 0)
Customer.objects.filter(pk=cancelled.pk).update(laghv_bazdid=True)
ids = self._ids()
self.assertNotIn(inactive.id, ids)
self.assertNotIn(cancelled.id, ids)
def test_search_inside_followup_view(self):
wanted = self._customer('علی رضایی')
other = self._customer('محمد حسینی')
self._coordination(wanted, 0)
self._coordination(other, 0)
ids = self._ids('&searchVal=رضایی&searchOn=name')
self.assertIn(wanted.id, ids)
self.assertNotIn(other.id, ids)
def test_ordered_by_earliest_followup_time(self):
late = self._customer('دیرتر')
early = self._customer('زودتر')
tz = timezone.get_current_timezone()
for customer, hour in ((late, 18), (early, 8)):
moment = timezone.make_aware(
datetime.datetime.combine(self.TODAY, datetime.time(hour, 0)), tz)
make_visit(customer, action_dt=moment, action_type='hamahangi',
status='finish')
response = self.client.get(self.URL + '?view=today')
order = [obj.id for obj in response.context['customers']]
self.assertLess(order.index(early.id), order.index(late.id))
def test_date_picker_and_nav_rendered(self):
customer = self._customer('نمایش تقویم')
self._coordination(customer, 0)
html = self.client.get(self.URL + '?view=today').content.decode()
self.assertIn('followupDatePicker', html)
self.assertIn('pDatepicker', html)
self.assertIn('ez-datebar', html)
self.assertIn('name="fdate"', html)
# پیش‌فرض صفحه = نمای پیگیری ⇒ نوار تاریخ باید در صفحهٔ بدون پارامتر هم باشد.
html_default = self.client.get(self.URL).content.decode()
self.assertIn('class="ez-datebar"', html_default)
self.assertIn('followupDatePicker', html_default)
# در نمای دیگر، نوار تاریخ رندر نشود.
html_other = self.client.get(self.URL + '?view=uncoordinated').content.decode()
self.assertNotIn('class="ez-datebar"', html_other)
self.assertNotIn('followupDatePicker', html_other)
def test_browsing_other_day_shows_banner(self):
customer = self._customer('روز دیگر')
self._coordination(customer, -1)
html = self.client.get(
self.URL + '?view=today&fdate=' + self._jalali(-1)).content.decode()
self.assertIn('بازگشت به امروز', html)
# ---------- ستون «تأخیر» فقط در نمای پیگیری مخفی ----------
def test_overdue_column_hidden_only_in_followup_view(self):
customer = self._customer('ستون تأخیر')
self._coordination(customer, 0)
followup_html = self.client.get(self.URL + '?view=today').content.decode()
self.assertNotIn('data-label="تأخیر"', followup_html)
self.assertNotIn('>تأخیر<', followup_html)
other_html = self.client.get(self.URL + '?view=uncoordinated').content.decode()
self.assertIn('data-label="تأخیر"', other_html)
def test_column_widths_sum_to_hundred_in_both_modes(self):
customer = self._customer('ردیف نمونه')
self._coordination(customer, 0)
for query in ('?view=today', '?view=all'):
html = self.client.get(self.URL + query).content.decode()
widths = [float(w) for w in re.findall(
r'<col style="width: (\d+(?:\.\d+)?)%">', html)]
self.assertAlmostEqual(sum(widths), 100.0, places=1,
msg=f'جمع عرض ستون‌ها در {query} برابر ۱۰۰ نیست')
# تعداد <th> با تعداد <td> هر ردیف باید یکی باشد.
thead = html.split('<tbody>')[0]
first_row = html.split('<tbody>')[1].split('</tr>')[0]
self.assertEqual(len(re.findall(r'<th[\s>]', thead)),
first_row.count('<td'))
# ---------- صفحه‌بندی ۱۰۰تایی ----------
def test_page_size_is_one_hundred(self):
for index in range(105):
self._customer(f'مشتری شماره {index}')
page = self.client.get(self.URL + '?view=all').context['customers']
self.assertEqual(len(page.object_list), 100)
self.assertEqual(page.paginator.num_pages, 2)
# ---------- هشدار پیگیری‌های عقب‌افتاده ----------
def test_overdue_warning_appears_for_past_due(self):
late = self._customer('موعد گذشته')
self._coordination(late, -3)
ctx = self.client.get(self.URL).context
self.assertEqual(ctx['counts']['followup_overdue'], 1)
html = self.client.get(self.URL).content.decode()
self.assertIn('class="ez-overdue-alert"', html)
self.assertIn('مورد نیازمند پیگیری از روزهای قبل باقی مانده', html)
def test_overdue_warning_absent_when_all_on_time(self):
today_only = self._customer('فقط امروز')
self._coordination(today_only, 0)
future = self._customer('فردا')
self._coordination(future, +2)
ctx = self.client.get(self.URL).context
self.assertEqual(ctx['counts']['followup_overdue'], 0)
self.assertNotIn('class="ez-overdue-alert"',
self.client.get(self.URL).content.decode())
def test_overdue_warning_matches_missed_list(self):
"""عدد هشدار باید دقیقاً برابر تعداد ردیف‌های فهرست «عقب‌افتاده‌ها» باشد."""
for offset in (-1, -5, -30, 0):
customer = self._customer(f'موعد {offset}')
self._coordination(customer, offset)
ctx = self.client.get(self.URL).context
missed = self.client.get(self.URL + '?view=today&missed=1').context
self.assertEqual(missed['customers'].paginator.count,
ctx['counts']['followup_overdue'])
self.assertEqual(missed['counts']['followup_overdue'],
ctx['counts']['followup_overdue'])
def test_newer_coordination_clears_overdue(self):
customer = self._customer('تمدید شده')
self._coordination(customer, -10)
self.assertEqual(self.client.get(self.URL).context['counts']['followup_overdue'], 1)
# کارشناس دوباره تماس گرفته و موعد جدید گذاشته ⇒ دیگر عقب‌افتاده نیست.
self._coordination(customer, +5)
self.assertEqual(self.client.get(self.URL).context['counts']['followup_overdue'], 0)
def test_scheduled_visit_clears_overdue(self):
customer = self._customer('بازدید گرفته')
self._coordination(customer, -10)
self._visit(customer, +1)
self.assertEqual(self.client.get(self.URL).context['counts']['followup_overdue'], 0)
def test_missed_mode_renders_chip_and_back_link(self):
customer = self._customer('عقب‌افتاده')
self._coordination(customer, -4)
html = self.client.get(self.URL + '?view=today&missed=1').content.decode()
self.assertIn('class="ez-overdue-mode"', html)
self.assertNotIn('<a class="ez-overdue-alert"', html)
self.assertIn('بازگشت به امروز', html)
def test_overdue_warning_only_in_followup_view(self):
customer = self._customer('عقب‌افتاده دیگر')
self._coordination(customer, -4)
html = self.client.get(self.URL + '?view=uncoordinated').content.decode()
self.assertNotIn('class="ez-overdue-alert"', html)