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)