diff --git a/myproject/create_wallet_payment_method.py b/myproject/create_wallet_payment_method.py new file mode 100644 index 0000000..f11a1e7 --- /dev/null +++ b/myproject/create_wallet_payment_method.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Скрипт для создания способа оплаты 'account_balance' для тенанта buba +""" +import os +import django + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings') +django.setup() + +from django.core.management import call_command +from django_tenants.utils import schema_context + +# Создаём способ оплаты для тенанта buba +with schema_context('buba'): + call_command('create_payment_methods') + print("\n✓ Способ оплаты успешно создан для тенанта 'buba'") diff --git a/myproject/customers/admin.py b/myproject/customers/admin.py index 23249ab..e15fbc5 100644 --- a/myproject/customers/admin.py +++ b/myproject/customers/admin.py @@ -1,6 +1,7 @@ from django.contrib import admin from django.db import models -from .models import Customer +from django.utils.html import format_html +from .models import Customer, WalletTransaction class IsSystemCustomerFilter(admin.SimpleListFilter): @@ -28,6 +29,7 @@ class CustomerAdmin(admin.ModelAdmin): 'full_name', 'email', 'phone', + 'wallet_balance_display', 'total_spent', 'is_system_customer', 'created_at' @@ -43,12 +45,15 @@ class CustomerAdmin(admin.ModelAdmin): ) date_hierarchy = 'created_at' ordering = ('-created_at',) - readonly_fields = ('created_at', 'updated_at', 'total_spent', 'is_system_customer') + readonly_fields = ('created_at', 'updated_at', 'total_spent', 'is_system_customer', 'wallet_balance') fieldsets = ( ('Основная информация', { 'fields': ('name', 'email', 'phone', 'is_system_customer') }), + ('Кошелёк', { + 'fields': ('wallet_balance',), + }), ('Статистика покупок', { 'fields': ('total_spent',), 'classes': ('collapse',) @@ -62,11 +67,22 @@ class CustomerAdmin(admin.ModelAdmin): }), ) + def wallet_balance_display(self, obj): + """Отображение баланса кошелька с цветом""" + if obj.wallet_balance > 0: + return format_html( + '{} руб.', + obj.wallet_balance + ) + return f'{obj.wallet_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', 'total_spent', 'is_system_customer', 'notes', 'created_at', 'updated_at'] + return ['name', 'email', 'phone', 'total_spent', 'is_system_customer', 'wallet_balance', 'notes', 'created_at', 'updated_at'] return self.readonly_fields def has_delete_permission(self, request, obj=None): @@ -85,3 +101,56 @@ class CustomerAdmin(admin.ModelAdmin): from django.contrib import messages messages.warning(request, 'Это системный клиент. Редактирование запрещено для обеспечения корректной работы системы.') return super().changeform_view(request, object_id, form_url, extra_context) + + +class WalletTransactionInline(admin.TabularInline): + """ +line для отображения транзакций кошелька""" + 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') + ordering = ('-created_at',) + + def has_add_permission(self, request, obj=None): + """Запрещаем ручное создание транзакций - только через сервис""" + return False + + +# Добавляем inline в CustomerAdmin +CustomerAdmin.inlines = [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') + search_fields = ('customer__name', 'customer__email', 'customer__phone', 'description') + readonly_fields = ('customer', 'transaction_type', 'amount', 'order', 'description', 'created_at', 'created_by') + date_hierarchy = 'created_at' + ordering = ('-created_at',) + + def amount_display(self, obj): + """Отображение суммы с цветом""" + if obj.transaction_type == 'deposit': + return format_html( + '+{} руб.', + obj.amount + ) + elif obj.transaction_type == 'spend': + return format_html( + '-{} руб.', + obj.amount + ) + return f'{obj.amount} руб.' + amount_display.short_description = 'Сумма' + + def has_add_permission(self, request): + """Запрещаем ручное создание - только через сервис""" + return False + + def has_delete_permission(self, request, obj=None): + """Запрещаем удаление - аудит должен быть неизменяем""" + return False diff --git a/myproject/customers/migrations/0004_customer_wallet_balance_wallettransaction.py b/myproject/customers/migrations/0004_customer_wallet_balance_wallettransaction.py new file mode 100644 index 0000000..d68f659 --- /dev/null +++ b/myproject/customers/migrations/0004_customer_wallet_balance_wallettransaction.py @@ -0,0 +1,41 @@ +# Generated by Django 5.0.10 on 2025-11-26 11:34 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customers', '0003_remove_customer_customers_c_loyalty_5162a0_idx_and_more'), + ('orders', '0004_refactor_models_and_add_payment_method'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='customer', + name='wallet_balance', + field=models.DecimalField(decimal_places=2, default=0, help_text='Остаток переплат клиента, доступный для оплаты заказов', max_digits=10, verbose_name='Баланс кошелька'), + ), + migrations.CreateModel( + name='WalletTransaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Сумма')), + ('transaction_type', models.CharField(choices=[('deposit', 'Пополнение'), ('spend', 'Списание'), ('adjustment', 'Корректировка')], max_length=20, verbose_name='Тип транзакции')), + ('description', models.TextField(blank=True, verbose_name='Описание')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Создано пользователем')), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='wallet_transactions', to='customers.customer', verbose_name='Клиент')), + ('order', models.ForeignKey(blank=True, help_text='Заказ, к которому относится транзакция (если применимо)', null=True, on_delete=django.db.models.deletion.PROTECT, to='orders.order', verbose_name='Заказ')), + ], + options={ + 'verbose_name': 'Транзакция кошелька', + 'verbose_name_plural': 'Транзакции кошелька', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['customer', '-created_at'], name='customers_w_custome_572f05_idx'), models.Index(fields=['transaction_type'], name='customers_w_transac_5fda02_idx'), models.Index(fields=['order'], name='customers_w_order_i_5e2527_idx')], + }, + ), + ] diff --git a/myproject/customers/models.py b/myproject/customers/models.py index 0181eae..8012221 100644 --- a/myproject/customers/models.py +++ b/myproject/customers/models.py @@ -32,6 +32,15 @@ class Customer(models.Model): 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, @@ -207,3 +216,74 @@ class Customer(models.Model): ) 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}" + diff --git a/myproject/customers/services/__init__.py b/myproject/customers/services/__init__.py new file mode 100644 index 0000000..0915984 --- /dev/null +++ b/myproject/customers/services/__init__.py @@ -0,0 +1,3 @@ +""" +Сервисы для работы с клиентами. +""" diff --git a/myproject/customers/services/wallet_service.py b/myproject/customers/services/wallet_service.py new file mode 100644 index 0000000..21b4d56 --- /dev/null +++ b/myproject/customers/services/wallet_service.py @@ -0,0 +1,193 @@ +""" +Сервис для работы с кошельком клиента. +Обрабатывает пополнения, списания и корректировки баланса. +""" +from decimal import Decimal, ROUND_HALF_UP +from django.db import transaction + + +# Константа для округления до 2 знаков +QUANTIZE_2D = Decimal('0.01') + + +def _quantize(value): + """Округление до 2 знаков после запятой""" + if isinstance(value, (int, float)): + value = Decimal(str(value)) + return value.quantize(QUANTIZE_2D, rounding=ROUND_HALF_UP) + + +class WalletService: + """ + Сервис для управления кошельком клиента. + Все операции атомарны и блокируют запись клиента для избежания race conditions. + """ + + @staticmethod + @transaction.atomic + def add_overpayment(order, user): + """ + Обработка переплаты по заказу. + Переносит излишек в кошелёк клиента и нормализует amount_paid заказа. + + Args: + order: Заказ с переплатой + user: Пользователь, инициировавший операцию + + Returns: + Decimal: Сумма переплаты или None, если переплаты нет + """ + from customers.models import Customer, WalletTransaction + + overpayment = order.amount_paid - order.total_amount + if overpayment <= 0: + return None + + # Блокируем запись клиента для обновления + customer = Customer.objects.select_for_update().get(pk=order.customer_id) + + # Округляем переплату до 2 знаков + overpayment = _quantize(overpayment) + + # Увеличиваем баланс кошелька + customer.wallet_balance = _quantize(customer.wallet_balance + overpayment) + customer.save(update_fields=['wallet_balance']) + + # Создаём транзакцию для аудита + WalletTransaction.objects.create( + customer=customer, + amount=overpayment, + transaction_type='deposit', + order=order, + description=f'Переплата по заказу #{order.order_number}', + created_by=user + ) + + # Нормализуем amount_paid заказа до total_amount + order.amount_paid = order.total_amount + order.save(update_fields=['amount_paid']) + + return overpayment + + @staticmethod + @transaction.atomic + def pay_with_wallet(order, amount, user): + """ + Оплата заказа из кошелька клиента. + Списывает средства с кошелька и создаёт платёж в заказе. + + Args: + order: Заказ для оплаты + amount: Запрашиваемая сумма для списания + user: Пользователь, инициировавший операцию + + Returns: + Decimal: Фактически списанная сумма или None + """ + from customers.models import Customer, WalletTransaction + from orders.models import Payment, PaymentMethod + + # Округляем запрошенную сумму + amount = _quantize(amount) + if amount <= 0: + return None + + # Блокируем запись клиента + customer = Customer.objects.select_for_update().get(pk=order.customer_id) + + # Остаток к оплате по заказу + amount_due = order.total_amount - order.amount_paid + + # Определяем фактическую сумму списания (минимум из трёх) + usable_amount = min(amount, customer.wallet_balance, amount_due) + usable_amount = _quantize(usable_amount) + + if usable_amount <= 0: + return None + + # Получаем способ оплаты "С баланса счёта" + try: + payment_method = PaymentMethod.objects.get(code='account_balance') + except PaymentMethod.DoesNotExist: + raise ValueError( + 'Способ оплаты "account_balance" не найден. ' + 'Запустите команду create_payment_methods.' + ) + + # Создаём платёж в заказе + Payment.objects.create( + order=order, + amount=usable_amount, + payment_method=payment_method, + created_by=user, + notes='Оплата из кошелька клиента' + ) + + # Уменьшаем баланс кошелька + customer.wallet_balance = _quantize(customer.wallet_balance - usable_amount) + customer.save(update_fields=['wallet_balance']) + + # Создаём транзакцию для аудита + WalletTransaction.objects.create( + customer=customer, + amount=usable_amount, + transaction_type='spend', + order=order, + description=f'Оплата заказа #{order.order_number} из кошелька', + created_by=user + ) + + return usable_amount + + @staticmethod + @transaction.atomic + def adjust_balance(customer_id, amount, description, user): + """ + Корректировка баланса кошелька администратором. + Может быть как положительной (пополнение), так и отрицательной (списание). + + Args: + customer_id: ID клиента + amount: Сумма корректировки (может быть отрицательной) + description: Обязательное описание причины корректировки + user: Пользователь, выполнивший корректировку + + 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('Сумма корректировки не может быть нулевой') + + # Блокируем запись клиента + 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, + description=description, + created_by=user + ) + + return txn diff --git a/myproject/customers/templates/customers/customer_detail.html b/myproject/customers/templates/customers/customer_detail.html index 643ac1b..dcb50d1 100644 --- a/myproject/customers/templates/customers/customer_detail.html +++ b/myproject/customers/templates/customers/customer_detail.html @@ -42,6 +42,27 @@
+ Баланс: + {{ order.customer.wallet_balance|floatformat:2 }} руб. +
+Можно использовать для оплаты этого заказа
+ + + + + + ++ Баланс: + {{ order.customer.wallet_balance|floatformat:2 }} руб. +
+ Заказ уже оплачен полностью +