- Создан новый класс SystemCustomerProtectionTestCase с 5 критичными тестами - Тест создания системного клиента с правильными атрибутами - Тест защиты от удаления системного клиента (ValidationError) - Тест защиты email системного клиента от изменения - Тест защиты флага is_system_customer от изменения - Тест что обычные клиенты не затронуты защитой - Исправлена логика в Customer.save(): проверка теперь использует original.is_system_customer - Добавлен импорт ValidationError из django.core.exceptions - Рефакторинг структуры тестов customers: - Разделены тесты по отдельным модулям в папке customers/tests/ - test_search_strategies.py - тесты стратегий поиска - test_system_customer.py - тесты защиты системного клиента - test_wallet_balance.py - тесты баланса кошелька - test_wallet_service.py - тесты WalletService - test_wallet_model.py - тесты модели WalletTransaction - Обновлён анализ тестов: 50 тестов (было 45), все проходят успешно - Критичная функциональность POS системы теперь покрыта тестами - Учтена tenant-система (используется TenantTestCase)
521 lines
21 KiB
Python
521 lines
21 KiB
Python
from decimal import Decimal
|
||
|
||
import phonenumbers
|
||
from django.core.cache import cache
|
||
from django.core.exceptions import ValidationError
|
||
from django.db import models
|
||
from django.db.models import Sum, Value, DecimalField as DjDecimalField
|
||
from django.db.models.functions import Coalesce
|
||
from phonenumber_field.modelfields import PhoneNumberField
|
||
|
||
|
||
class Customer(models.Model):
|
||
"""
|
||
Модель клиента для цветочного магазина в Минске, Беларусь.
|
||
"""
|
||
# Name field that is not required to be unique
|
||
name = models.CharField(max_length=200, blank=True, verbose_name="Имя")
|
||
|
||
email = models.EmailField(blank=True, null=True, verbose_name="Email")
|
||
|
||
# Phone with validation using django-phonenumber-field
|
||
phone = PhoneNumberField(
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Телефон",
|
||
help_text="Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат"
|
||
)
|
||
|
||
# Temporary field to store raw phone number during initialization
|
||
_raw_phone = None
|
||
|
||
# System customer flag
|
||
is_system_customer = models.BooleanField(
|
||
default=False,
|
||
db_index=True,
|
||
verbose_name="Системный клиент",
|
||
help_text="Автоматически созданный клиент для анонимных покупок и наличных продаж"
|
||
)
|
||
|
||
# Additional notes
|
||
notes = models.TextField(
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Заметки",
|
||
help_text="Заметки о клиенте, особые предпочтения и т.д."
|
||
)
|
||
|
||
|
||
|
||
# Timestamps
|
||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||
|
||
class Meta:
|
||
verbose_name = "Клиент"
|
||
verbose_name_plural = "Клиенты"
|
||
indexes = [
|
||
models.Index(fields=['name']),
|
||
models.Index(fields=['email']),
|
||
models.Index(fields=['phone']),
|
||
models.Index(fields=['created_at']),
|
||
]
|
||
ordering = ['-created_at']
|
||
|
||
def __str__(self):
|
||
if self.name:
|
||
return self.name
|
||
if self.phone:
|
||
return str(self.phone)
|
||
if self.email:
|
||
return self.email
|
||
return "Безымянный клиент"
|
||
|
||
@property
|
||
def full_name(self):
|
||
"""Полное имя клиента"""
|
||
return self.name
|
||
|
||
def clean_phone(self):
|
||
"""Пользовательская очистка поля телефона для нормализации перед валидацией."""
|
||
if self.phone:
|
||
try:
|
||
# Парсим номер телефона, чтобы проверить его валидность и нормализовать
|
||
raw_phone = str(self.phone)
|
||
|
||
# Если начинается с '8' и имеет 11 цифр, это может быть российский формат
|
||
if raw_phone.startswith('8') and len(raw_phone) == 11:
|
||
# Сначала пробуем BY для белорусских номеров
|
||
parsed = phonenumbers.parse(raw_phone, "BY")
|
||
if phonenumbers.is_valid_number(parsed):
|
||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||
|
||
# Если BY не работает, пробуем RU как запасной вариант
|
||
parsed = phonenumbers.parse(raw_phone, "RU")
|
||
if phonenumbers.is_valid_number(parsed):
|
||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||
|
||
# Пробуем парсить без кода страны (может быть уже в международном формате)
|
||
parsed = phonenumbers.parse(raw_phone, None)
|
||
if phonenumbers.is_valid_number(parsed):
|
||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||
|
||
except phonenumbers.NumberParseException:
|
||
# Если парсинг не удался, возвращаем как есть и позволяем валидации поля обработать это
|
||
pass
|
||
|
||
return self.phone
|
||
|
||
def clean_fields(self, exclude=None):
|
||
# Нормализуем телефон перед запуском проверки полей
|
||
if not exclude:
|
||
exclude = []
|
||
if 'phone' not in exclude and self.phone:
|
||
normalized = None
|
||
try:
|
||
normalized = self.clean_phone()
|
||
except Exception:
|
||
normalized = None
|
||
if normalized:
|
||
# присваиваем нормализованное значение (E.164), чтобы PhoneNumberField видел корректный формат
|
||
self.phone = normalized
|
||
|
||
super().clean_fields(exclude=exclude)
|
||
|
||
def clean(self):
|
||
"""Дополнительная валидация при необходимости."""
|
||
super().clean()
|
||
|
||
def save(self, *args, **kwargs):
|
||
# Защита системного клиента от изменений
|
||
if self.pk:
|
||
# Получаем оригинальный объект из БД
|
||
try:
|
||
original = Customer.objects.get(pk=self.pk)
|
||
# Проверяем, что это системный клиент в БД
|
||
if original.is_system_customer:
|
||
# Проверяем, не пытаются ли изменить критичные поля
|
||
if original.email != self.email:
|
||
raise ValidationError("Нельзя изменить email системного клиента")
|
||
if original.is_system_customer != self.is_system_customer:
|
||
raise ValidationError("Нельзя изменить флаг системного клиента")
|
||
except Customer.DoesNotExist:
|
||
pass
|
||
|
||
# Обеспечиваем нормализацию телефона, даже если save вызывается напрямую (не через форму)
|
||
# На данный момент, если вызов прошел через валидацию формы, телефон уже должен быть нормализован
|
||
# Но если save вызывается непосредственно в модели, нам все равно нужно нормализовать
|
||
if self.phone and str(self.phone).startswith('8') and len(str(self.phone)) == 11:
|
||
# Это, вероятно, номер в формате страны, который требует нормализации
|
||
try:
|
||
# Сначала пробуем BY для белорусских номеров
|
||
parsed = phonenumbers.parse(str(self.phone), "BY")
|
||
if phonenumbers.is_valid_number(parsed):
|
||
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||
else:
|
||
# Если BY не работает, пробуем RU как запасной вариант
|
||
parsed = phonenumbers.parse(str(self.phone), "RU")
|
||
if phonenumbers.is_valid_number(parsed):
|
||
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||
except phonenumbers.NumberParseException:
|
||
# Если парсинг не удался, не меняем и позволяем валидации поля обработать это
|
||
pass
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
def delete(self, *args, **kwargs):
|
||
"""Защита системного клиента от удаления"""
|
||
if self.is_system_customer:
|
||
raise ValidationError("Нельзя удалить системного клиента. Он необходим для работы системы.")
|
||
super().delete(*args, **kwargs)
|
||
|
||
@classmethod
|
||
def get_or_create_system_customer(cls):
|
||
"""
|
||
Получить или создать системного клиента для анонимных покупок.
|
||
|
||
Системный клиент используется для:
|
||
- Анонимных покупок в POS системе
|
||
- Покупок от неизвестных клиентов (проходимость)
|
||
- Наличных продаж без указания покупателя
|
||
|
||
Возвращает:
|
||
tuple: (customer, created) - объект клиента и флаг создания
|
||
"""
|
||
customer, created = cls.objects.get_or_create(
|
||
email="system@pos.customer",
|
||
defaults={
|
||
"name": "АНОНИМНЫЙ ПОКУПАТЕЛЬ (POS)",
|
||
"is_system_customer": True,
|
||
"notes": "SYSTEM_CUSTOMER - автоматически созданный клиент для анонимных покупок и наличных продаж",
|
||
}
|
||
)
|
||
return customer, created
|
||
|
||
# Методы-обёртки для работы с кошельком (вся логика в WalletService)
|
||
def pay_from_wallet(self, order, amount, user):
|
||
"""
|
||
Оплатить заказ из кошелька клиента.
|
||
Обёртка над WalletService.pay_with_wallet.
|
||
|
||
Args:
|
||
order: Заказ для оплаты
|
||
amount: Сумма к списанию
|
||
user: Пользователь, инициирующий операцию
|
||
|
||
Returns:
|
||
Decimal: Фактически списанная сумма или None
|
||
"""
|
||
from customers.services.wallet_service import WalletService
|
||
return WalletService.pay_with_wallet(order, amount, user)
|
||
|
||
def adjust_wallet(self, amount, description, user):
|
||
"""
|
||
Корректировка баланса кошелька (для админа).
|
||
Обёртка над WalletService.adjust_balance.
|
||
|
||
Args:
|
||
amount: Сумма корректировки (может быть отрицательной)
|
||
description: Обязательное описание причины
|
||
user: Пользователь, выполняющий корректировку
|
||
|
||
Returns:
|
||
WalletTransaction: Созданная транзакция
|
||
"""
|
||
from customers.services.wallet_service import WalletService
|
||
return WalletService.adjust_balance(self.pk, amount, description, user)
|
||
|
||
@property
|
||
def wallet_transactions_history(self):
|
||
"""
|
||
История транзакций кошелька клиента.
|
||
|
||
Returns:
|
||
QuerySet: WalletTransaction для этого клиента
|
||
"""
|
||
return self.wallet_transactions.all()
|
||
|
||
# ========== МЕТОДЫ ВЫЧИСЛЯЕМОГО БАЛАНСА КОШЕЛЬКА ==========
|
||
|
||
def get_wallet_balance(self, category='money', use_cache=True):
|
||
"""
|
||
Вычисляет баланс кошелька как SUM(signed_amount) транзакций.
|
||
|
||
Args:
|
||
category: 'money' или 'bonus' (для будущей бонусной системы)
|
||
use_cache: использовать кеш (по умолчанию True)
|
||
|
||
Returns:
|
||
Decimal: текущий баланс
|
||
"""
|
||
if not self.pk:
|
||
return Decimal('0')
|
||
|
||
cache_key = f'wallet_balance:{self.pk}:{category}'
|
||
|
||
if use_cache:
|
||
cached = cache.get(cache_key)
|
||
if cached is not None:
|
||
return Decimal(str(cached))
|
||
|
||
result = self.wallet_transactions.filter(
|
||
balance_category=category
|
||
).aggregate(
|
||
total=Coalesce(
|
||
Sum('signed_amount'),
|
||
Value(0),
|
||
output_field=DjDecimalField()
|
||
)
|
||
)
|
||
|
||
balance = result['total'] or Decimal('0')
|
||
|
||
if use_cache:
|
||
cache.set(cache_key, str(balance), timeout=300) # 5 минут
|
||
|
||
return balance
|
||
|
||
@property
|
||
def wallet_balance(self):
|
||
"""
|
||
Баланс кошелька (реальные деньги).
|
||
Обратная совместимость: используется в templates и существующем коде.
|
||
|
||
Returns:
|
||
Decimal: текущий баланс кошелька
|
||
"""
|
||
return self.get_wallet_balance(category='money')
|
||
|
||
def invalidate_wallet_cache(self, category='money'):
|
||
"""Сбросить кеш баланса кошелька."""
|
||
cache_key = f'wallet_balance:{self.pk}:{category}'
|
||
cache.delete(cache_key)
|
||
|
||
# Для будущей бонусной системы:
|
||
# @property
|
||
# def bonus_balance(self):
|
||
# """Баланс бонусных баллов."""
|
||
# return self.get_wallet_balance(category='bonus')
|
||
|
||
def get_successful_orders_total(self, start_date=None, end_date=None):
|
||
"""
|
||
Получить сумму успешных заказов за указанный период.
|
||
|
||
Args:
|
||
start_date: Дата начала периода (DateField или None)
|
||
end_date: Дата окончания периода (DateField или None)
|
||
|
||
Returns:
|
||
Decimal: Сумма успешных заказов
|
||
"""
|
||
from django.db.models import Sum, Value, DecimalField
|
||
from django.db.models.functions import Coalesce
|
||
from decimal import Decimal
|
||
|
||
# Базовый queryset: только успешные заказы
|
||
queryset = self.orders.filter(status__is_positive_end=True)
|
||
|
||
# Фильтрация по датам (используем delivery__delivery_date после рефакторинга)
|
||
if start_date:
|
||
queryset = queryset.filter(delivery__delivery_date__gte=start_date)
|
||
if end_date:
|
||
queryset = queryset.filter(delivery__delivery_date__lte=end_date)
|
||
|
||
# Агрегация суммы
|
||
result = queryset.aggregate(
|
||
total=Coalesce(
|
||
Sum('total_amount'),
|
||
Value(0),
|
||
output_field=DecimalField()
|
||
)
|
||
)
|
||
|
||
return result['total'] or Decimal('0')
|
||
|
||
def get_last_year_orders_total(self):
|
||
"""
|
||
Получить сумму успешных заказов за последний календарный год.
|
||
(С этой даты прошлого года по текущую дату)
|
||
|
||
Returns:
|
||
Decimal: Сумма успешных заказов за год
|
||
"""
|
||
from datetime import date, timedelta
|
||
|
||
today = date.today()
|
||
year_ago = today - timedelta(days=365)
|
||
|
||
return self.get_successful_orders_total(start_date=year_ago, end_date=today)
|
||
|
||
|
||
class ContactChannel(models.Model):
|
||
"""Канал связи с клиентом (телефон, email, соцсети)"""
|
||
|
||
CHANNEL_TYPES = [
|
||
('phone', 'Телефон'),
|
||
('email', 'Email'),
|
||
('telegram', 'Telegram'),
|
||
('instagram', 'Instagram'),
|
||
('whatsapp', 'WhatsApp'),
|
||
('viber', 'Viber'),
|
||
('vk', 'ВКонтакте'),
|
||
('facebook', 'Facebook'),
|
||
('other', 'Другое'),
|
||
]
|
||
|
||
customer = models.ForeignKey(
|
||
'Customer',
|
||
on_delete=models.CASCADE,
|
||
related_name='contact_channels',
|
||
verbose_name="Клиент"
|
||
)
|
||
channel_type = models.CharField(
|
||
max_length=20,
|
||
choices=CHANNEL_TYPES,
|
||
verbose_name="Тип канала"
|
||
)
|
||
value = models.CharField(
|
||
max_length=255,
|
||
verbose_name="Значение",
|
||
help_text="Username, номер телефона, email и т.д."
|
||
)
|
||
is_primary = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Основной"
|
||
)
|
||
notes = models.CharField(
|
||
max_length=255,
|
||
blank=True,
|
||
verbose_name="Примечание"
|
||
)
|
||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||
|
||
class Meta:
|
||
unique_together = ['channel_type', 'value']
|
||
indexes = [
|
||
models.Index(fields=['channel_type', 'value']),
|
||
models.Index(fields=['customer']),
|
||
]
|
||
verbose_name = "Канал связи"
|
||
verbose_name_plural = "Каналы связи"
|
||
ordering = ['-is_primary', 'channel_type']
|
||
|
||
def __str__(self):
|
||
return f"{self.get_channel_type_display()}: {self.value}"
|
||
|
||
|
||
class WalletTransaction(models.Model):
|
||
"""
|
||
Транзакция по кошельку клиента.
|
||
Хранит историю всех пополнений, списаний и корректировок баланса.
|
||
|
||
Архитектура: баланс кошелька = SUM(signed_amount) всех транзакций клиента.
|
||
Это единственный источник правды о балансе.
|
||
"""
|
||
|
||
# Типы транзакций (расширяемо для будущей бонусной системы)
|
||
TRANSACTION_TYPE_CHOICES = [
|
||
('deposit', 'Пополнение'),
|
||
('spend', 'Списание'),
|
||
('adjustment', 'Корректировка'),
|
||
# Для будущей бонусной системы:
|
||
# ('bonus_accrual', 'Начисление бонусов'),
|
||
# ('bonus_spend', 'Списание бонусов'),
|
||
# ('cashback', 'Кэшбэк'),
|
||
]
|
||
|
||
# Категории баланса (для разделения "реальные деньги" vs "бонусы")
|
||
BALANCE_CATEGORY_CHOICES = [
|
||
('money', 'Реальные деньги'),
|
||
# ('bonus', 'Бонусные баллы'), # Для будущей реализации
|
||
]
|
||
|
||
customer = models.ForeignKey(
|
||
'Customer',
|
||
on_delete=models.PROTECT,
|
||
related_name='wallet_transactions',
|
||
verbose_name="Клиент"
|
||
)
|
||
|
||
# Знаковая сумма: положительная = приход, отрицательная = расход
|
||
signed_amount = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
verbose_name="Сумма",
|
||
help_text="Положительная для пополнений, отрицательная для списаний"
|
||
)
|
||
|
||
transaction_type = models.CharField(
|
||
max_length=20,
|
||
choices=TRANSACTION_TYPE_CHOICES,
|
||
verbose_name="Тип транзакции"
|
||
)
|
||
|
||
# Категория баланса (подготовка к бонусной системе)
|
||
balance_category = models.CharField(
|
||
max_length=20,
|
||
choices=BALANCE_CATEGORY_CHOICES,
|
||
default='money',
|
||
verbose_name="Категория"
|
||
)
|
||
|
||
order = models.ForeignKey(
|
||
'orders.Order',
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.PROTECT,
|
||
related_name='wallet_transactions',
|
||
verbose_name="Заказ",
|
||
help_text="Заказ, к которому относится транзакция (если применимо)"
|
||
)
|
||
|
||
description = models.TextField(
|
||
blank=True,
|
||
verbose_name="Описание"
|
||
)
|
||
|
||
created_at = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name="Дата создания"
|
||
)
|
||
|
||
created_by = models.ForeignKey(
|
||
'accounts.CustomUser',
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.SET_NULL,
|
||
verbose_name="Создано пользователем"
|
||
)
|
||
|
||
# Баланс после транзакции (для быстрого аудита и отображения в истории)
|
||
balance_after = models.DecimalField(
|
||
max_digits=10,
|
||
decimal_places=2,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Баланс после",
|
||
help_text="Баланс кошелька после применения этой транзакции"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Транзакция кошелька"
|
||
verbose_name_plural = "Транзакции кошелька"
|
||
ordering = ['-created_at']
|
||
indexes = [
|
||
models.Index(fields=['customer', '-created_at']),
|
||
models.Index(fields=['transaction_type']),
|
||
models.Index(fields=['order']),
|
||
models.Index(fields=['balance_category']),
|
||
models.Index(fields=['customer', 'balance_category']),
|
||
]
|
||
|
||
def __str__(self):
|
||
sign = '+' if self.signed_amount >= 0 else ''
|
||
return f"{self.get_transaction_type_display()} {sign}{self.signed_amount} руб. для {self.customer}"
|
||
|
||
@property
|
||
def amount(self):
|
||
"""Абсолютная сумма (для обратной совместимости)."""
|
||
return abs(self.signed_amount)
|
||
|