import phonenumbers from django.core.exceptions import ValidationError from django.db import models 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, unique=True, verbose_name="Email") # Phone with validation using django-phonenumber-field phone = PhoneNumberField( blank=True, null=True, unique=True, verbose_name="Телефон", help_text="Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат" ) # Temporary field to store raw phone number during initialization _raw_phone = None # Wallet balance for overpayments wallet_balance = models.DecimalField( max_digits=10, decimal_places=2, default=0, verbose_name="Баланс кошелька", help_text="Остаток переплат клиента, доступный для оплаты заказов" ) # 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 validate_unique(self, exclude=None): """Переопределение для корректной проверки уникальности телефона при обновлениях""" # Снова нормализуем номер телефона перед проверкой уникальности if self.phone: # Проверяем существующих клиентов с таким же телефоном (исключая текущий экземпляр при обновлении) existing = Customer.objects.filter(phone=self.phone) if self.pk: existing = existing.exclude(pk=self.pk) if existing.exists(): raise ValidationError({'phone': 'Пользователь с таким номером телефона уже существует.'}) # Вызываем родительский validate_unique для обработки других проверок super().validate_unique(exclude=exclude) 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 and self.is_system_customer: # Получаем оригинальный объект из БД try: original = Customer.objects.get(pk=self.pk) # Проверяем, не пытаются ли изменить критичные поля 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_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 WalletTransaction(models.Model): """ Транзакция по кошельку клиента. Хранит историю всех пополнений, списаний и корректировок баланса. """ TRANSACTION_TYPE_CHOICES = [ ('deposit', 'Пополнение'), ('spend', 'Списание'), ('adjustment', 'Корректировка'), ] customer = models.ForeignKey( 'Customer', on_delete=models.PROTECT, related_name='wallet_transactions', verbose_name="Клиент" ) amount = models.DecimalField( max_digits=10, decimal_places=2, verbose_name="Сумма" ) transaction_type = models.CharField( max_length=20, choices=TRANSACTION_TYPE_CHOICES, verbose_name="Тип транзакции" ) order = models.ForeignKey( 'orders.Order', null=True, blank=True, on_delete=models.PROTECT, 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="Создано пользователем" ) 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']), ] def __str__(self): return f"{self.get_transaction_type_display()} {self.amount} руб. для {self.customer}"