Files
octopus/myproject/customers/models.py
Andrey Smakotin dbbac933af Добавлены тесты защиты системного клиента и рефакторинг структуры тестов
- Создан новый класс 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)
2025-12-28 00:32:45 +03:00

521 lines
21 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.
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)