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 total_spent = models.DecimalField( max_digits=10, decimal_places=2, default=0, verbose_name="Общая сумма покупок" ) # 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 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}"