diff --git a/myproject/customers/admin.py b/myproject/customers/admin.py index 027a29d..9326883 100644 --- a/myproject/customers/admin.py +++ b/myproject/customers/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from django.db import models from django.utils.html import format_html -from .models import Customer, WalletTransaction +from .models import Customer, WalletTransaction, ContactChannel class IsSystemCustomerFilter(admin.SimpleListFilter): @@ -44,14 +44,14 @@ class CustomerAdmin(admin.ModelAdmin): ) date_hierarchy = 'created_at' ordering = ('-created_at',) - readonly_fields = ('created_at', 'updated_at', 'is_system_customer', 'wallet_balance') + readonly_fields = ('created_at', 'updated_at', 'is_system_customer', 'wallet_balance_display') fieldsets = ( ('Основная информация', { 'fields': ('name', 'email', 'phone', 'is_system_customer') }), ('Кошелёк', { - 'fields': ('wallet_balance',), + 'fields': ('wallet_balance_display',), }), ('Заметки', { 'fields': ('notes',) @@ -64,20 +64,20 @@ class CustomerAdmin(admin.ModelAdmin): def wallet_balance_display(self, obj): """Отображение баланса кошелька с цветом""" - if obj.wallet_balance > 0: + balance = obj.wallet_balance + if balance > 0: return format_html( '{} руб.', - obj.wallet_balance + balance ) - return f'{obj.wallet_balance} руб.' + return f'{balance} руб.' wallet_balance_display.short_description = 'Баланс кошелька' - wallet_balance_display.admin_order_field = 'wallet_balance' def get_readonly_fields(self, request, obj=None): """Делаем все поля read-only для системного клиента""" if obj and obj.is_system_customer: # Для системного клиента все поля только для чтения - return ['name', 'email', 'phone', 'is_system_customer', 'wallet_balance', 'notes', 'created_at', 'updated_at'] + return ['name', 'email', 'phone', 'is_system_customer', 'wallet_balance_display', 'notes', 'created_at', 'updated_at'] return self.readonly_fields def has_delete_permission(self, request, obj=None): @@ -98,14 +98,20 @@ class CustomerAdmin(admin.ModelAdmin): return super().changeform_view(request, object_id, form_url, extra_context) +class ContactChannelInline(admin.TabularInline): + """Inline для управления каналами связи клиента""" + model = ContactChannel + extra = 1 + fields = ('channel_type', 'value', 'is_primary', 'notes') + + class WalletTransactionInline(admin.TabularInline): - """ -line для отображения транзакций кошелька""" + """Inline для отображения транзакций кошелька""" model = WalletTransaction extra = 0 can_delete = False - readonly_fields = ('transaction_type', 'amount', 'order', 'description', 'created_at', 'created_by') - fields = ('created_at', 'transaction_type', 'amount', 'order', 'description', 'created_by') + readonly_fields = ('created_at', 'transaction_type', 'signed_amount', 'balance_after', 'order', 'description', 'created_by') + fields = ('created_at', 'transaction_type', 'signed_amount', 'balance_after', 'order', 'description', 'created_by') ordering = ('-created_at',) def has_add_permission(self, request, obj=None): @@ -114,32 +120,33 @@ line для отображения транзакций кошелька""" # Добавляем inline в CustomerAdmin -CustomerAdmin.inlines = [WalletTransactionInline] +CustomerAdmin.inlines = [ContactChannelInline, WalletTransactionInline] @admin.register(WalletTransaction) class WalletTransactionAdmin(admin.ModelAdmin): """Админка для просмотра всех транзакций кошелька""" - list_display = ('created_at', 'customer', 'transaction_type', 'amount_display', 'order', 'created_by') - list_filter = ('transaction_type', 'created_at') + list_display = ('created_at', 'customer', 'transaction_type', 'amount_display', 'balance_after', 'order', 'created_by') + list_filter = ('transaction_type', 'balance_category', 'created_at') search_fields = ('customer__name', 'customer__email', 'customer__phone', 'description') - readonly_fields = ('customer', 'transaction_type', 'amount', 'order', 'description', 'created_at', 'created_by') + readonly_fields = ('customer', 'transaction_type', 'signed_amount', 'balance_category', 'balance_after', 'order', 'description', 'created_at', 'created_by') date_hierarchy = 'created_at' ordering = ('-created_at',) def amount_display(self, obj): """Отображение суммы с цветом""" - if obj.transaction_type == 'deposit': + amount = obj.signed_amount + if amount > 0: return format_html( '+{} руб.', - obj.amount + amount ) - elif obj.transaction_type == 'spend': + elif amount < 0: return format_html( - '-{} руб.', - obj.amount + '{} руб.', + amount ) - return f'{obj.amount} руб.' + return f'{amount} руб.' amount_display.short_description = 'Сумма' def has_add_permission(self, request): diff --git a/myproject/customers/forms.py b/myproject/customers/forms.py index 55ba3c9..5807f82 100644 --- a/myproject/customers/forms.py +++ b/myproject/customers/forms.py @@ -2,7 +2,7 @@ from django import forms from django.core.exceptions import ValidationError from phonenumber_field.formfields import PhoneNumberField from phonenumber_field.widgets import PhoneNumberPrefixWidget -from .models import Customer +from .models import Customer, ContactChannel class CustomerForm(forms.ModelForm): phone = PhoneNumberField( @@ -35,43 +35,17 @@ class CustomerForm(forms.ModelForm): field.widget.attrs.update({'class': 'form-control'}) def clean_email(self): - """Проверяет уникальность email при создании/редактировании""" + """Нормализует пустые значения email в None""" email = self.cleaned_data.get('email') - - # Нормализуем пустые значения в None (Django best practice для nullable полей) if not email: return None - - # Проверяем уникальность - queryset = Customer.objects.filter(email=email) - - # При редактировании исключаем текущий экземпляр - if self.instance and self.instance.pk: - queryset = queryset.exclude(pk=self.instance.pk) - - if queryset.exists(): - raise ValidationError('Клиент с таким email уже существует.') - return email def clean_phone(self): - """Проверяет уникальность телефона при создании/редактировании""" + """Нормализует пустые значения телефона в None""" phone = self.cleaned_data.get('phone') - - # Нормализуем пустые значения в None (Django best practice для nullable полей) if not phone: return None - - # Проверяем уникальность - queryset = Customer.objects.filter(phone=phone) - - # При редактировании исключаем текущий экземпляр - if self.instance and self.instance.pk: - queryset = queryset.exclude(pk=self.instance.pk) - - if queryset.exists(): - raise ValidationError('Клиент с таким номером телефона уже существует.') - return phone def clean(self): @@ -85,4 +59,35 @@ class CustomerForm(forms.ModelForm): 'Он необходим для корректной работы системы и создается автоматически.' ) - return cleaned_data \ No newline at end of file + return cleaned_data + + +class ContactChannelForm(forms.ModelForm): + """Форма для добавления/редактирования канала связи""" + + class Meta: + model = ContactChannel + fields = ['channel_type', 'value', 'is_primary', 'notes'] + widgets = { + 'channel_type': forms.Select(attrs={'class': 'form-select'}), + 'value': forms.TextInput(attrs={'class': 'form-control', 'placeholder': '@username, номер и т.д.'}), + 'notes': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Личный аккаунт, рабочий...'}), + 'is_primary': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + } + + def clean_value(self): + value = self.cleaned_data.get('value', '').strip() + channel_type = self.cleaned_data.get('channel_type') + + if not value: + raise ValidationError('Значение не может быть пустым') + + # Проверка уникальности комбинации channel_type + value + qs = ContactChannel.objects.filter(channel_type=channel_type, value=value) + if self.instance.pk: + qs = qs.exclude(pk=self.instance.pk) + if qs.exists(): + type_display = dict(ContactChannel.CHANNEL_TYPES).get(channel_type, channel_type) + raise ValidationError(f'Такой {type_display} уже существует у другого клиента') + + return value \ No newline at end of file diff --git a/myproject/customers/migrations/0003_alter_customer_email_alter_customer_phone_and_more.py b/myproject/customers/migrations/0003_alter_customer_email_alter_customer_phone_and_more.py new file mode 100644 index 0000000..35cf7af --- /dev/null +++ b/myproject/customers/migrations/0003_alter_customer_email_alter_customer_phone_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 5.0.10 on 2025-12-27 19:32 + +import django.db.models.deletion +import phonenumber_field.modelfields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customers', '0002_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='customer', + name='email', + field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email'), + ), + migrations.AlterField( + model_name='customer', + name='phone', + field=phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат', max_length=128, null=True, region=None, verbose_name='Телефон'), + ), + migrations.CreateModel( + name='ContactChannel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('channel_type', models.CharField(choices=[('phone', 'Телефон'), ('email', 'Email'), ('telegram', 'Telegram'), ('instagram', 'Instagram'), ('whatsapp', 'WhatsApp'), ('viber', 'Viber'), ('vk', 'ВКонтакте'), ('facebook', 'Facebook'), ('other', 'Другое')], max_length=20, verbose_name='Тип канала')), + ('value', models.CharField(help_text='Username, номер телефона, email и т.д.', max_length=255, verbose_name='Значение')), + ('is_primary', models.BooleanField(default=False, verbose_name='Основной')), + ('notes', models.CharField(blank=True, max_length=255, verbose_name='Примечание')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_channels', to='customers.customer', verbose_name='Клиент')), + ], + options={ + 'verbose_name': 'Канал связи', + 'verbose_name_plural': 'Каналы связи', + 'ordering': ['-is_primary', 'channel_type'], + 'indexes': [models.Index(fields=['channel_type', 'value'], name='customers_c_channel_179e89_idx'), models.Index(fields=['customer'], name='customers_c_custome_f14e0e_idx')], + 'unique_together': {('channel_type', 'value')}, + }, + ), + ] diff --git a/myproject/customers/migrations/0004_wallet_refactoring.py b/myproject/customers/migrations/0004_wallet_refactoring.py new file mode 100644 index 0000000..eaf7183 --- /dev/null +++ b/myproject/customers/migrations/0004_wallet_refactoring.py @@ -0,0 +1,146 @@ +# Generated by Django 5.0.10 on 2025-12-27 20:24 + +import django.db.models.deletion +from decimal import Decimal +from django.conf import settings +from django.db import migrations, models + + +def populate_signed_amount(apps, schema_editor): + """ + Заполняем signed_amount на основе старого amount и типа транзакции. + spend -> отрицательная сумма + deposit/adjustment -> положительная сумма + """ + WalletTransaction = apps.get_model('customers', 'WalletTransaction') + + for txn in WalletTransaction.objects.all(): + if txn.transaction_type == 'spend': + txn.signed_amount = -abs(txn.amount) + else: + # deposit, adjustment - положительные + txn.signed_amount = abs(txn.amount) + txn.save(update_fields=['signed_amount']) + + +def calculate_balance_after(apps, schema_editor): + """ + Вычисляем balance_after для всех существующих транзакций. + """ + Customer = apps.get_model('customers', 'Customer') + WalletTransaction = apps.get_model('customers', 'WalletTransaction') + + for customer in Customer.objects.all(): + running_balance = Decimal('0') + + # Обрабатываем транзакции в хронологическом порядке + for txn in WalletTransaction.objects.filter(customer=customer).order_by('created_at'): + running_balance += txn.signed_amount or Decimal('0') + txn.balance_after = running_balance + txn.save(update_fields=['balance_after']) + + +def reverse_populate(apps, schema_editor): + """Обратная операция - ничего не делаем.""" + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('customers', '0003_alter_customer_email_alter_customer_phone_and_more'), + ('orders', '0008_historicalorder_needs_delivery_photo_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + # 1. Добавляем новые поля (signed_amount временно nullable) + migrations.AddField( + model_name='wallettransaction', + name='signed_amount', + field=models.DecimalField( + decimal_places=2, + max_digits=10, + null=True, # Временно nullable для миграции данных + help_text='Положительная для пополнений, отрицательная для списаний', + verbose_name='Сумма' + ), + ), + migrations.AddField( + model_name='wallettransaction', + name='balance_after', + field=models.DecimalField( + blank=True, + decimal_places=2, + help_text='Баланс кошелька после применения этой транзакции', + max_digits=10, + null=True, + verbose_name='Баланс после' + ), + ), + migrations.AddField( + model_name='wallettransaction', + name='balance_category', + field=models.CharField( + choices=[('money', 'Реальные деньги')], + default='money', + max_length=20, + verbose_name='Категория' + ), + ), + + # 2. Копируем данные из amount в signed_amount + migrations.RunPython(populate_signed_amount, reverse_populate), + + # 3. Вычисляем balance_after + migrations.RunPython(calculate_balance_after, reverse_populate), + + # 4. Делаем signed_amount NOT NULL + migrations.AlterField( + model_name='wallettransaction', + name='signed_amount', + field=models.DecimalField( + decimal_places=2, + max_digits=10, + help_text='Положительная для пополнений, отрицательная для списаний', + verbose_name='Сумма' + ), + ), + + # 5. Удаляем старое поле amount + migrations.RemoveField( + model_name='wallettransaction', + name='amount', + ), + + # 6. Удаляем wallet_balance из Customer + migrations.RemoveField( + model_name='customer', + name='wallet_balance', + ), + + # 7. Обновляем связь с Order (добавляем related_name) + migrations.AlterField( + model_name='wallettransaction', + name='order', + field=models.ForeignKey( + blank=True, + help_text='Заказ, к которому относится транзакция (если применимо)', + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='wallet_transactions', + to='orders.order', + verbose_name='Заказ' + ), + ), + + # 8. Добавляем индексы + migrations.AddIndex( + model_name='wallettransaction', + index=models.Index(fields=['balance_category'], name='customers_w_balance_81f0a9_idx'), + ), + migrations.AddIndex( + model_name='wallettransaction', + index=models.Index(fields=['customer', 'balance_category'], name='customers_w_custome_060570_idx'), + ), + ] diff --git a/myproject/customers/models.py b/myproject/customers/models.py index b6ebdf4..ea852bb 100644 --- a/myproject/customers/models.py +++ b/myproject/customers/models.py @@ -1,6 +1,11 @@ +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 @@ -11,13 +16,12 @@ 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") + email = models.EmailField(blank=True, null=True, verbose_name="Email") # Phone with validation using django-phonenumber-field phone = PhoneNumberField( - blank=True, - null=True, - unique=True, + blank=True, + null=True, verbose_name="Телефон", help_text="Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат" ) @@ -25,15 +29,6 @@ class Customer(models.Model): # 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, @@ -81,20 +76,6 @@ class Customer(models.Model): """Полное имя клиента""" 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: @@ -252,6 +233,68 @@ class Customer(models.Model): """ 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): """ Получить сумму успешных заказов за указанный период. @@ -303,15 +346,86 @@ class Customer(models.Model): 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( @@ -321,10 +435,12 @@ class WalletTransaction(models.Model): verbose_name="Клиент" ) - amount = models.DecimalField( + # Знаковая сумма: положительная = приход, отрицательная = расход + signed_amount = models.DecimalField( max_digits=10, decimal_places=2, - verbose_name="Сумма" + verbose_name="Сумма", + help_text="Положительная для пополнений, отрицательная для списаний" ) transaction_type = models.CharField( @@ -333,11 +449,20 @@ class WalletTransaction(models.Model): 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="Заказ, к которому относится транзакция (если применимо)" ) @@ -360,6 +485,16 @@ class WalletTransaction(models.Model): 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 = "Транзакции кошелька" @@ -368,8 +503,16 @@ class WalletTransaction(models.Model): 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): - return f"{self.get_transaction_type_display()} {self.amount} руб. для {self.customer}" + 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) diff --git a/myproject/customers/services/wallet_service.py b/myproject/customers/services/wallet_service.py index 3be13df..311c4a0 100644 --- a/myproject/customers/services/wallet_service.py +++ b/myproject/customers/services/wallet_service.py @@ -1,8 +1,10 @@ """ Сервис для работы с кошельком клиента. -Обрабатывает пополнения, списания и корректировки баланса. +Все операции создают транзакции в WalletTransaction. +Баланс вычисляется как SUM(signed_amount). """ from decimal import Decimal, ROUND_HALF_UP + from django.db import transaction @@ -20,20 +22,168 @@ def _quantize(value): class WalletService: """ Сервис для управления кошельком клиента. - Все операции атомарны и блокируют запись клиента для избежания race conditions. + + Архитектура: + - Баланс = SUM(signed_amount) транзакций (нет денормализованного поля) + - Все операции атомарны с блокировкой строк + - Кеширование баланса для производительности + - Инвалидация кеша при каждой транзакции """ + @staticmethod + @transaction.atomic + def create_transaction( + customer, + amount, + transaction_type, + category='money', + order=None, + description='', + user=None + ): + """ + Создать транзакцию кошелька (базовый метод). + + Args: + customer: Customer или customer_id + amount: Decimal - положительная сумма + transaction_type: str - 'deposit', 'spend', 'adjustment' + category: str - 'money' или 'bonus' + order: Order - связанный заказ (опционально) + description: str - описание + user: CustomUser - кто создал + + Returns: + WalletTransaction + + Raises: + ValueError: если некорректные данные или недостаточно средств + """ + from customers.models import Customer, WalletTransaction + + # Получаем и блокируем клиента + if isinstance(customer, int): + customer = Customer.objects.select_for_update().get(pk=customer) + else: + customer = Customer.objects.select_for_update().get(pk=customer.pk) + + amount = _quantize(amount) + if amount <= 0: + raise ValueError('Сумма должна быть положительной') + + # Определяем знак суммы + if transaction_type == 'spend': + signed_amount = -amount + else: + signed_amount = amount + + # Получаем текущий баланс (без кеша для точности) + current_balance = customer.get_wallet_balance(category=category, use_cache=False) + + # Проверяем баланс для списания + if signed_amount < 0: + if current_balance + signed_amount < 0: + raise ValueError( + f'Недостаточно средств. Баланс: {current_balance}, ' + f'запрошено: {abs(signed_amount)}' + ) + + # Вычисляем баланс после транзакции + balance_after = current_balance + signed_amount + + # Создаём транзакцию + txn = WalletTransaction.objects.create( + customer=customer, + signed_amount=signed_amount, + transaction_type=transaction_type, + balance_category=category, + order=order, + description=description, + created_by=user, + balance_after=balance_after + ) + + # Инвалидируем кеш + customer.invalidate_wallet_cache(category=category) + + return txn + + @staticmethod + @transaction.atomic + def create_adjustment(customer, amount, description, user, category='money'): + """ + Корректировка баланса (может быть положительной или отрицательной). + + Используется для административных операций: + - Пополнение кошелька + - Списание средств + - Исправление ошибок + + Args: + customer: Customer или customer_id + amount: Decimal - сумма (может быть отрицательной) + description: str - обязательное описание + user: CustomUser + category: str - 'money' или 'bonus' + + Returns: + WalletTransaction + """ + from customers.models import Customer, WalletTransaction + + if not description or not description.strip(): + raise ValueError('Описание обязательно для корректировки баланса') + + amount = _quantize(amount) + if amount == 0: + raise ValueError('Сумма корректировки не может быть нулевой') + + # Получаем и блокируем клиента + if isinstance(customer, int): + customer = Customer.objects.select_for_update().get(pk=customer) + else: + customer = Customer.objects.select_for_update().get(pk=customer.pk) + + # Получаем текущий баланс + current_balance = customer.get_wallet_balance(category=category, use_cache=False) + + # Проверяем, что баланс не уйдёт в минус + if current_balance + amount < 0: + raise ValueError( + f'Корректировка приведёт к отрицательному балансу. ' + f'Текущий баланс: {current_balance}, корректировка: {amount}' + ) + + # Вычисляем баланс после + balance_after = current_balance + amount + + # Создаём транзакцию + txn = WalletTransaction.objects.create( + customer=customer, + signed_amount=amount, # Может быть положительной или отрицательной + transaction_type='adjustment', + balance_category=category, + order=None, + description=description, + created_by=user, + balance_after=balance_after + ) + + # Инвалидируем кеш + customer.invalidate_wallet_cache(category=category) + + return txn + @staticmethod @transaction.atomic def pay_with_wallet(order, amount, user): """ Оплата заказа из кошелька клиента. - Создаёт транзакцию. Списание из кошелька происходит автоматически в Transaction.save(). Args: order: Заказ для оплаты - amount: Запрашиваемая сумма для списания - user: Пользователь, инициировавший операцию + amount: Запрашиваемая сумма + user: Пользователь Returns: Decimal: Фактически списанная сумма или None @@ -41,26 +191,28 @@ class WalletService: from customers.models import Customer from orders.services.transaction_service import TransactionService - # Округляем запрошенную сумму amount = _quantize(amount) if amount <= 0: return None - # Блокируем запись клиента для проверки баланса + # Блокируем клиента для проверки баланса customer = Customer.objects.select_for_update().get(pk=order.customer_id) - # Остаток к оплате по заказу + # Текущий баланс + wallet_balance = customer.get_wallet_balance(use_cache=False) + + # Остаток к оплате amount_due = order.total_amount - order.amount_paid - # Определяем фактическую сумму списания (минимум из трёх) - usable_amount = min(amount, customer.wallet_balance, amount_due) + # Фактическая сумма (минимум из трёх) + usable_amount = min(amount, wallet_balance, amount_due) usable_amount = _quantize(usable_amount) if usable_amount <= 0: return None - # Создаём транзакцию - # Transaction.save() автоматически спишет из кошелька и создаст WalletTransaction + # Создаём транзакцию платежа + # Transaction.save() вызовет create_wallet_spend() TransactionService.create_payment( order=order, amount=usable_amount, @@ -76,12 +228,11 @@ class WalletService: def refund_wallet_payment(order, amount, user): """ Возврат средств в кошелёк. - Используется для создания транзакции возврата с кошельком. Args: - order: Заказ, по которому был платёж + order: Заказ amount: Сумма возврата - user: Пользователь, инициировавший возврат + user: Пользователь Returns: Decimal: Возвращённая сумма @@ -93,7 +244,7 @@ class WalletService: return None # Создаём транзакцию возврата - # Transaction.save() автоматически вернёт в кошелёк и создаст WalletTransaction + # Transaction.save() вызовет create_wallet_deposit() TransactionService.create_refund( order=order, amount=amount, @@ -108,51 +259,100 @@ class WalletService: @transaction.atomic def adjust_balance(customer_id, amount, description, user): """ - Корректировка баланса кошелька администратором. - Может быть как положительной (пополнение), так и отрицательной (списание). + Корректировка баланса (обёртка для обратной совместимости). Args: customer_id: ID клиента - amount: Сумма корректировки (может быть отрицательной) - description: Обязательное описание причины корректировки - user: Пользователь, выполнивший корректировку + amount: Сумма (может быть отрицательной) + description: Описание + user: Пользователь Returns: - WalletTransaction: Созданная транзакция + WalletTransaction """ - from customers.models import Customer, WalletTransaction - - if not description or not description.strip(): - raise ValueError('Описание обязательно для корректировки баланса') - - amount = _quantize(amount) - if amount == 0: - raise ValueError('Сумма корректировки не может быть нулевой') - - # Блокируем запись клиента - customer = Customer.objects.select_for_update().get(pk=customer_id) - - # Применяем корректировку - new_balance = _quantize(customer.wallet_balance + amount) - - # Проверяем, что баланс не уйдёт в минус - if new_balance < 0: - raise ValueError( - f'Корректировка приведёт к отрицательному балансу ' - f'({new_balance} руб.). Операция отклонена.' - ) - - customer.wallet_balance = new_balance - customer.save(update_fields=['wallet_balance']) - - # Создаём транзакцию - txn = WalletTransaction.objects.create( - customer=customer, - amount=abs(amount), - transaction_type='adjustment', - order=None, + return WalletService.create_adjustment( + customer=customer_id, + amount=amount, description=description, - created_by=user + user=user ) - return txn + # ========== МЕТОДЫ ДЛЯ ВЫЗОВА ИЗ Transaction.save() ========== + + @staticmethod + @transaction.atomic + def create_wallet_spend(order, amount, user): + """ + Списание из кошелька при оплате заказа. + Вызывается из Transaction.save() при payment. + + Args: + order: Заказ + amount: Сумма списания + user: Пользователь + + Returns: + WalletTransaction + """ + return WalletService.create_transaction( + customer=order.customer, + amount=amount, + transaction_type='spend', + order=order, + description=f'Оплата по заказу #{order.order_number}', + user=user + ) + + @staticmethod + @transaction.atomic + def create_wallet_deposit(order, amount, user): + """ + Пополнение кошелька при возврате. + Вызывается из Transaction.save() при refund. + + Args: + order: Заказ + amount: Сумма возврата + user: Пользователь + + Returns: + WalletTransaction + """ + return WalletService.create_transaction( + customer=order.customer, + amount=amount, + transaction_type='deposit', + order=order, + description=f'Возврат по заказу #{order.order_number}', + user=user + ) + + # ========== МЕТОДЫ ДЛЯ БУДУЩЕЙ БОНУСНОЙ СИСТЕМЫ ========== + + # @staticmethod + # @transaction.atomic + # def accrue_bonus(customer, amount, reason, user=None, order=None): + # """Начислить бонусные баллы.""" + # return WalletService.create_transaction( + # customer=customer, + # amount=amount, + # transaction_type='bonus_accrual', + # category='bonus', + # order=order, + # description=reason, + # user=user + # ) + + # @staticmethod + # @transaction.atomic + # def spend_bonus(customer, amount, order, user): + # """Списать бонусы за оплату.""" + # return WalletService.create_transaction( + # customer=customer, + # amount=amount, + # transaction_type='bonus_spend', + # category='bonus', + # order=order, + # description=f'Оплата бонусами по заказу #{order.order_number}', + # user=user + # ) diff --git a/myproject/customers/templates/customers/customer_detail.html b/myproject/customers/templates/customers/customer_detail.html index 548757e..d18065f 100644 --- a/myproject/customers/templates/customers/customer_detail.html +++ b/myproject/customers/templates/customers/customer_detail.html @@ -74,6 +74,60 @@ + +
Нет дополнительных каналов связи. Добавьте Instagram, Telegram и другие контакты.
+ {% endif %} +