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 @@ Сумма покупок: {{ customer.total_spent|floatformat:2 }} руб. + + Баланс кошелька: + + {% if customer.wallet_balance > 0 %} + {{ customer.wallet_balance|floatformat:2 }} руб. + {% else %} + {{ customer.wallet_balance|floatformat:2 }} руб. + {% endif %} + + + + Общий долг по активным заказам: + + {% if total_debt > 0 %} + {{ total_debt|floatformat:2 }} руб. + (Кол-во заказов: {{ active_orders_count }}) + {% else %} + 0.00 руб. + {% endif %} + + Заметки: {{ customer.notes|default:"Нет" }} diff --git a/myproject/customers/views.py b/myproject/customers/views.py index ccedc4f..f487ca8 100644 --- a/myproject/customers/views.py +++ b/myproject/customers/views.py @@ -85,8 +85,14 @@ def customer_detail(request, pk): if customer.is_system_customer: return render(request, 'customers/customer_system.html') + # Рассчитываем общий долг по активным заказам + active_orders = customer.orders.exclude(payment_status='paid') + total_debt = sum(order.amount_due for order in active_orders) + context = { 'customer': customer, + 'total_debt': total_debt, + 'active_orders_count': active_orders.count(), } return render(request, 'customers/customer_detail.html', context) diff --git a/myproject/orders/management/commands/create_payment_methods.py b/myproject/orders/management/commands/create_payment_methods.py index 723677d..509d1a5 100644 --- a/myproject/orders/management/commands/create_payment_methods.py +++ b/myproject/orders/management/commands/create_payment_methods.py @@ -8,6 +8,13 @@ class Command(BaseCommand): def handle(self, *args, **options): payment_methods = [ + { + 'code': 'account_balance', + 'name': 'С баланса счёта', + 'description': 'Оплата из кошелька клиента', + 'is_system': True, + 'order': 0 + }, { 'code': 'cash', 'name': 'Наличными', diff --git a/myproject/orders/models/payment.py b/myproject/orders/models/payment.py index 03a0635..4020d86 100644 --- a/myproject/orders/models/payment.py +++ b/myproject/orders/models/payment.py @@ -142,3 +142,11 @@ class Payment(models.Model): # Пересчитываем общую сумму оплаты в заказе self.order.amount_paid = sum(p.amount for p in self.order.payments.all()) self.order.update_payment_status() + + # Нормализация переплаты: лишнее в кошелёк, amount_paid = total_amount + try: + from customers.services.wallet_service import WalletService + WalletService.add_overpayment(self.order, self.created_by) + except Exception: + # Если обработка переплаты не удалась, продолжаем без ошибок + pass diff --git a/myproject/orders/templates/orders/order_detail.html b/myproject/orders/templates/orders/order_detail.html index 0c833c0..1549d9d 100644 --- a/myproject/orders/templates/orders/order_detail.html +++ b/myproject/orders/templates/orders/order_detail.html @@ -236,6 +236,64 @@
+ + {% if order.customer and order.customer.wallet_balance > 0 and order.amount_due > 0 %} +
+
+
Кошелёк клиента
+
+
+

+ Баланс: + {{ order.customer.wallet_balance|floatformat:2 }} руб. +

+

Можно использовать для оплаты этого заказа

+ + +
+ {% csrf_token %} + + +
+ + +
+ {% csrf_token %} +
+ + +
+ Введите сумму для списания из кошелька +
+
+
+ {% elif order.customer and order.customer.wallet_balance > 0 %} +
+
+
Кошелёк клиента
+
+
+

+ Баланс: + {{ order.customer.wallet_balance|floatformat:2 }} руб. +

+ Заказ уже оплачен полностью +
+
+ {% endif %} +
diff --git a/myproject/orders/urls.py b/myproject/orders/urls.py index fe12996..1050862 100644 --- a/myproject/orders/urls.py +++ b/myproject/orders/urls.py @@ -16,6 +16,9 @@ urlpatterns = [ path('create-draft/', views.create_draft_from_form, name='order-create-draft'), path('api/customer-address-history/', views.get_customer_address_history, name='api-customer-address-history'), + # Wallet payment + path('/apply-wallet/', views.apply_wallet_payment, name='apply-wallet'), + # Order Status Management URLs path('statuses/', views.order_status_list, name='status_list'), path('statuses/create/', views.order_status_create, name='status_create'), diff --git a/myproject/orders/views.py b/myproject/orders/views.py index 2cb2954..2388fcc 100644 --- a/myproject/orders/views.py +++ b/myproject/orders/views.py @@ -7,6 +7,7 @@ from django.views.decorators.http import require_http_methods from django.contrib.auth.decorators import login_required from django.core.exceptions import ValidationError from django.db import models +from decimal import Decimal from .models import Order, OrderItem, Address, OrderStatus from .forms import OrderForm, OrderItemFormSet, OrderStatusForm, PaymentFormSet from .filters import OrderFilter @@ -604,3 +605,47 @@ def order_status_delete(request, pk): # === ВРЕМЕННЫЕ КОМПЛЕКТЫ === # УДАЛЕНО: Логика создания временных комплектов перенесена в products.services.kit_service # Используйте API endpoint: products:api-temporary-kit-create + + +# === КОШЕЛЁК КЛИЕНТА === + +@login_required +def apply_wallet_payment(request, pk): + """ + Применение оплаты из кошелька клиента к заказу. + Вызывается через POST-запрос с суммой для списания. + """ + if request.method != 'POST': + return redirect('orders:order-detail', pk=pk) + + order = get_object_or_404(Order, pk=pk) + + # Получаем запрашиваемую сумму из формы + try: + raw_amount = request.POST.get('wallet_amount', '0') + amount = Decimal(str(raw_amount).replace(',', '.')) + except (ValueError, TypeError, ArithmeticError): + messages.error(request, 'Некорректная сумма для списания из кошелька.') + return redirect('orders:order-detail', pk=pk) + + # Вызываем сервис для оплаты из кошелька + try: + from customers.services.wallet_service import WalletService + paid_amount = WalletService.pay_with_wallet(order, amount, request.user) + + if paid_amount and paid_amount > 0: + messages.success( + request, + f'Из кошелька клиента списано {paid_amount} руб. для оплаты заказа #{order.order_number}.' + ) + else: + messages.warning( + request, + 'Не удалось списать средства из кошелька. Проверьте баланс и сумму заказа.' + ) + except ValueError as e: + messages.error(request, str(e)) + except Exception as e: + messages.error(request, f'Ошибка при оплате из кошелька: {str(e)}') + + return redirect('orders:order-detail', pk=pk) diff --git a/myproject/test_wallet_system.py b/myproject/test_wallet_system.py new file mode 100644 index 0000000..439cee7 --- /dev/null +++ b/myproject/test_wallet_system.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Тест системы кошелька клиента +""" +import os +import django + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings') +django.setup() + +from django_tenants.utils import schema_context +from customers.models import Customer, WalletTransaction +from orders.models import Order, PaymentMethod + +print("\n" + "="*60) +print("ТЕСТ СИСТЕМЫ КОШЕЛЬКА КЛИЕНТА") +print("="*60) + +with schema_context('buba'): + # 1. Проверяем способ оплаты + try: + method = PaymentMethod.objects.get(code='account_balance') + print(f"\n✓ Способ оплаты найден: {method.name}") + print(f" Описание: {method.description}") + print(f" Порядок: {method.order}") + except PaymentMethod.DoesNotExist: + print("\n✗ Способ оплаты 'account_balance' не найден!") + + # 2. Проверяем клиентов + customers = Customer.objects.filter(is_system_customer=False) + print(f"\n✓ Всего клиентов: {customers.count()}") + + if customers.exists(): + customer = customers.first() + print(f"\n Тестовый клиент: {customer.name}") + print(f" Баланс кошелька: {customer.wallet_balance} руб.") + print(f" Всего покупок: {customer.total_spent} руб.") + + # Транзакции + txn_count = customer.wallet_transactions.count() + print(f" Транзакций кошелька: {txn_count}") + + if txn_count > 0: + print("\n Последние транзакции:") + for txn in customer.wallet_transactions.all()[:5]: + print(f" - {txn.created_at.strftime('%d.%m.%Y %H:%M')}: " + f"{txn.get_transaction_type_display()} " + f"{txn.amount} руб.") + + # 3. Проверяем заказы + orders = Order.objects.all() + print(f"\n✓ Всего заказов: {orders.count()}") + + if orders.exists(): + order = orders.first() + print(f"\n Тестовый заказ: #{order.order_number}") + print(f" Клиент: {order.customer.name}") + print(f" Сумма: {order.total_amount} руб.") + print(f" Оплачено: {order.amount_paid} руб.") + print(f" К оплате: {order.amount_due} руб.") + print(f" Статус оплаты: {order.get_payment_status_display()}") + + # Платежи + payments = order.payments.all() + if payments.exists(): + print(f"\n Платежи по заказу:") + for payment in payments: + print(f" - {payment.payment_method.name}: {payment.amount} руб.") + +print("\n" + "="*60) +print("ТЕСТ ЗАВЕРШЁН") +print("="*60 + "\n") diff --git a/nested-singing-rainbow.md b/nested-singing-rainbow.md new file mode 100644 index 0000000..e22d11a --- /dev/null +++ b/nested-singing-rainbow.md @@ -0,0 +1,771 @@ +# План реализации системы личного счета клиента + +## Обзор + +Реализация системы личного счета для клиентов цветочного магазина с поддержкой резервирования средств, смешанной оплаты, автоматического пополнения при переплате и полной историей транзакций. + +## Ключевые бизнес-требования + +1. **Баланс счета**: У каждого клиента есть личный счет (может быть положительным или отрицательным) +2. **Пополнение**: Вручную администратором или автоматически при переплате заказа +3. **Кредитование**: Разрешен отрицательный баланс для доверенных клиентов +4. **История операций**: Полный аудит всех операций со счетом +5. **Смешанная оплата**: Можно комбинировать с другими способами оплаты +6. **Резервирование**: При создании заказа средства резервируются, при завершении списываются +7. **Управление**: Только администраторы/менеджеры имеют доступ + +--- + +## 1. Изменения в базе данных + +### 1.1 Расширение модели Customer + +**Файл**: `myproject/customers/models.py` + +Добавить поля для управления балансом: + +```python +# Поля баланса +account_balance = models.DecimalField( + max_digits=10, + decimal_places=2, + default=0, + verbose_name="Баланс счета", + help_text="Текущий баланс лицевого счета клиента" +) + +available_balance = models.DecimalField( + max_digits=10, + decimal_places=2, + default=0, + verbose_name="Доступный баланс", + help_text="Баланс за вычетом зарезервированных средств" +) + +reserved_balance = models.DecimalField( + max_digits=10, + decimal_places=2, + default=0, + verbose_name="Зарезервировано", + help_text="Сумма, зарезервированная под активные заказы" +) + +allow_negative_balance = models.BooleanField( + default=False, + verbose_name="Разрешить отрицательный баланс", + help_text="Позволяет клиенту уходить в минус" +) + +negative_balance_limit = models.DecimalField( + max_digits=10, + decimal_places=2, + default=0, + verbose_name="Лимит кредита", + help_text="Максимальная сумма отрицательного баланса (0 = без лимита)" +) +``` + +**Взаимосвязь полей**: +- `account_balance` = Общий баланс клиента +- `reserved_balance` = Сумма, зарезервированная под заказы +- `available_balance` = `account_balance` - `reserved_balance` + +### 1.2 Новая модель AccountTransaction + +**Файл**: `myproject/customers/models.py` (или отдельный файл в models/) + +Модель для хранения истории всех операций со счетом: + +```python +class AccountTransaction(models.Model): + """ + Транзакция по лицевому счету клиента. + """ + TRANSACTION_TYPE_CHOICES = [ + ('deposit', 'Пополнение вручную'), + ('auto_deposit', 'Авто-пополнение (переплата)'), + ('reservation', 'Резервирование'), + ('reservation_release', 'Снятие резерва'), + ('charge', 'Списание за заказ'), + ('refund', 'Возврат средств'), + ('adjustment', 'Корректировка баланса'), + ] + + STATUS_CHOICES = [ + ('active', 'Активна'), + ('completed', 'Завершена'), + ('cancelled', 'Отменена'), + ] + + customer = models.ForeignKey( + 'Customer', + on_delete=models.PROTECT, + related_name='account_transactions' + ) + + transaction_type = models.CharField(max_length=30, choices=TRANSACTION_TYPE_CHOICES) + amount = models.DecimalField(max_digits=10, decimal_places=2) + + balance_before = models.DecimalField(max_digits=10, decimal_places=2) + balance_after = models.DecimalField(max_digits=10, decimal_places=2) + + order = models.ForeignKey('orders.Order', null=True, blank=True, on_delete=models.PROTECT) + payment = models.ForeignKey('orders.Payment', null=True, blank=True, on_delete=models.SET_NULL) + related_transaction = models.ForeignKey('self', null=True, blank=True, on_delete=models.SET_NULL) + + description = models.TextField() + notes = models.TextField(blank=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='completed') + + created_at = models.DateTimeField(auto_now_add=True) + created_by = models.ForeignKey('accounts.CustomUser', null=True, blank=True, on_delete=models.SET_NULL) + + class Meta: + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['customer', '-created_at']), + models.Index(fields=['transaction_type']), + models.Index(fields=['order']), + models.Index(fields=['status']), + ] +``` + +### 1.3 Новый способ оплаты + +Добавить в команду `create_payment_methods.py`: + +```python +{ + 'code': 'account_balance', + 'name': 'С баланса счета', + 'description': 'Списание с личного счета клиента', + 'is_system': True, + 'order': 0 # Первый в списке +} +``` + +--- + +## 2. Бизнес-логика: AccountBalanceService + +**Новый файл**: `myproject/customers/services/account_balance_service.py` + +Создать сервис с методами: + +### Основные методы: + +1. **`deposit(customer, amount, description, user, notes)`** + - Пополнение счета вручную администратором + - Увеличивает `account_balance` и `available_balance` + - Создает транзакцию типа `deposit` + +2. **`auto_deposit_from_overpayment(order, overpayment_amount, user)`** + - Автоматическое пополнение при переплате + - Вызывается когда `order.amount_paid > order.total_amount` + - Создает транзакцию типа `auto_deposit` + +3. **`reserve_balance(customer, order, amount, user)`** + - Резервирование средств при создании заказа с оплатой со счета + - Проверяет достаточность средств (с учетом кредита) + - Уменьшает `available_balance`, увеличивает `reserved_balance` + - Создает транзакцию типа `reservation` со статусом `active` + +4. **`charge_reserved_balance(reservation_transaction, user)`** + - Списание зарезервированных средств при завершении заказа + - Уменьшает `account_balance` и `reserved_balance` + - Обновляет статус резервирования на `completed` + - Создает транзакцию типа `charge` + +5. **`release_reservation(reservation_transaction, user)`** + - Снятие резервирования при отмене заказа + - Увеличивает `available_balance`, уменьшает `reserved_balance` + - Обновляет статус резервирования на `cancelled` + - Создает транзакцию типа `reservation_release` + +6. **`refund(customer, amount, order, description, user, notes)`** + - Возврат средств на счет + - Используется при индивидуальных решениях по возвратам + - Создает транзакцию типа `refund` + +7. **`adjustment(customer, amount, description, user, notes)`** + - Корректировка баланса администратором + - Может быть положительной или отрицательной + - Требует обязательное описание + +### Ключевые особенности реализации: + +- Все методы используют `@transaction.atomic` для атомарности +- `select_for_update()` для блокировки записи клиента при изменении +- Проверка лимитов кредита перед резервированием +- Запись `balance_before` и `balance_after` для аудита + +--- + +## 3. Интеграция с существующей системой платежей + +### 3.1 Модификация Payment.save() + +**Файл**: `myproject/orders/models/payment.py` + +В методе `save()` добавить логику: + +```python +def save(self, *args, **kwargs): + is_new = self.pk is None + super().save(*args, **kwargs) + + # Пересчитываем сумму оплаты + self.order.amount_paid = sum(p.amount for p in self.order.payments.all()) + + # Обработка оплаты с баланса счета + if self.payment_method.code == 'account_balance' and is_new: + from customers.services.account_balance_service import AccountBalanceService + AccountBalanceService.reserve_balance( + customer=self.order.customer, + order=self.order, + amount=self.amount, + user=self.created_by + ) + + self.order.update_payment_status() + + # Проверка переплаты + if self.order.amount_paid > self.order.total_amount: + overpayment = self.order.amount_paid - self.order.total_amount + from customers.services.account_balance_service import AccountBalanceService + AccountBalanceService.auto_deposit_from_overpayment( + order=self.order, + overpayment_amount=overpayment, + user=self.created_by + ) +``` + +### 3.2 Обработка изменения статуса заказа + +**Новый файл**: `myproject/orders/signals.py` + +Создать сигналы для автоматической обработки: + +```python +from django.db.models.signals import post_save +from django.dispatch import receiver +from .models import Order + +@receiver(post_save, sender=Order) +def handle_order_status_change(sender, instance, created, **kwargs): + """Обработка изменения статуса заказа""" + if created or not instance.status: + return + + from customers.models import AccountTransaction + from customers.services.account_balance_service import AccountBalanceService + + # Заказ выполнен успешно - списываем + if instance.status.is_positive_end: + reservations = AccountTransaction.objects.filter( + order=instance, + transaction_type='reservation', + status='active' + ) + for reservation in reservations: + AccountBalanceService.charge_reserved_balance( + reservation_transaction=reservation, + user=instance.modified_by + ) + + # Заказ отменен - снимаем резерв + elif instance.status.is_negative_end: + reservations = AccountTransaction.objects.filter( + order=instance, + transaction_type='reservation', + status='active' + ) + for reservation in reservations: + AccountBalanceService.release_reservation( + reservation_transaction=reservation, + user=instance.modified_by + ) +``` + +Подключить сигналы в `myproject/orders/apps.py`: + +```python +class OrdersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'orders' + + def ready(self): + import orders.signals # noqa +``` + +--- + +## 4. Административный интерфейс + +### 4.1 Расширение CustomerAdmin + +**Файл**: `myproject/customers/admin.py` + +Изменения: + +1. **Добавить поля баланса в list_display**: + ```python + list_display = ( + 'full_name', 'email', 'phone', + 'account_balance_colored', # новое + 'available_balance_display', # новое + 'reserved_balance_display', # новое + 'total_spent', + 'is_system_customer', + 'created_at' + ) + ``` + +2. **Добавить фильтр по кредиту**: + ```python + list_filter = ( + IsSystemCustomerFilter, + 'allow_negative_balance', # новое + 'created_at' + ) + ``` + +3. **Добавить секцию баланса в fieldsets**: + ```python + ('Баланс лицевого счета', { + 'fields': ( + 'account_balance', + 'available_balance', + 'reserved_balance', + 'allow_negative_balance', + 'negative_balance_limit', + ), + 'classes': ('wide',), + }), + ``` + +4. **Добавить inline для транзакций**: + ```python + class AccountTransactionInline(admin.TabularInline): + model = AccountTransaction + extra = 0 + can_delete = False + readonly_fields = [...] + + inlines = [AccountTransactionInline] + ``` + +5. **Добавить actions**: + ```python + actions = [ + 'add_deposit', + 'add_refund', + 'add_adjustment', + 'enable_negative_balance', + ] + ``` + +### 4.2 Новый AccountTransactionAdmin + +**Файл**: `myproject/customers/admin.py` + +Создать отдельную админку для просмотра всех транзакций: + +```python +@admin.register(AccountTransaction) +class AccountTransactionAdmin(admin.ModelAdmin): + list_display = [ + 'created_at', 'customer_link', 'transaction_type', + 'amount_colored', 'balance_after', 'order_link', 'status' + ] + list_filter = ['transaction_type', 'status', 'created_at'] + search_fields = ['customer__name', 'customer__email', 'description'] + readonly_fields = [все поля] + + def has_add_permission(self, request): + return False # Только через сервис + + def has_delete_permission(self, request, obj=None): + return False # Аудит, нельзя удалять +``` + +### 4.3 Кастомные views для операций + +**Новый файл**: `myproject/customers/admin_views.py` + +Создать views для: +- Пополнения баланса (`/admin/customers/deposit/`) +- Возврата средств (`/admin/customers/refund/`) +- Корректировки (`/admin/customers/adjustment/`) + +**Новый файл**: `myproject/customers/admin_urls.py` + +```python +from django.urls import path +from . import admin_views + +urlpatterns = [ + path('deposit/', admin_views.deposit_view, name='customer_deposit'), + path('refund/', admin_views.refund_view, name='customer_refund'), + path('adjustment/', admin_views.adjustment_view, name='customer_adjustment'), +] +``` + +Подключить в основной `urls.py`. + +--- + +## 5. Формы для операций + +**Новый файл**: `myproject/customers/forms.py` + +Создать формы: + +```python +class DepositForm(forms.Form): + """Форма пополнения баланса""" + customer = forms.ModelChoiceField(queryset=Customer.objects.all()) + amount = forms.DecimalField(min_value=0.01, max_digits=10, decimal_places=2) + description = forms.CharField(widget=forms.Textarea) + notes = forms.CharField(widget=forms.Textarea, required=False) + +class RefundForm(forms.Form): + """Форма возврата средств""" + customer = forms.ModelChoiceField(queryset=Customer.objects.all()) + amount = forms.DecimalField(min_value=0.01, max_digits=10, decimal_places=2) + order = forms.ModelChoiceField(queryset=Order.objects.all(), required=False) + description = forms.CharField(widget=forms.Textarea) + notes = forms.CharField(widget=forms.Textarea, required=False) + +class AdjustmentForm(forms.Form): + """Форма корректировки баланса""" + customer = forms.ModelChoiceField(queryset=Customer.objects.all()) + amount = forms.DecimalField(max_digits=10, decimal_places=2) # может быть отрицательным + description = forms.CharField(widget=forms.Textarea) + notes = forms.CharField(widget=forms.Textarea, required=False) +``` + +--- + +## 6. UI/UX улучшения + +### 6.1 Отображение баланса в форме заказа + +**Файл**: `myproject/orders/templates/orders/order_form.html` + +Добавить блок с информацией о балансе клиента: + +```html +{% if order.customer %} +
+
Баланс клиента
+
    +
  • Общий баланс: {{ order.customer.account_balance }} руб.
  • +
  • Доступно: {{ order.customer.available_balance }} руб.
  • +
  • Зарезервировано: {{ order.customer.reserved_balance }} руб.
  • +
+
+{% endif %} +``` + +### 6.2 Валидация при выборе оплаты со счета + +**Файл**: `myproject/orders/static/orders/js/payment_validation.js` + +Добавить JS-валидацию: + +```javascript +// Проверка достаточности средств при выборе оплаты со счета +function validateAccountBalance(paymentMethodCode, amount, availableBalance, allowNegative, creditLimit) { + if (paymentMethodCode === 'account_balance') { + if (amount > availableBalance && !allowNegative) { + alert('Недостаточно средств на счете клиента!'); + return false; + } + if (allowNegative && creditLimit > 0) { + let potentialBalance = availableBalance - amount; + if (Math.abs(potentialBalance) > creditLimit) { + alert('Превышен лимит кредита клиента!'); + return false; + } + } + } + return true; +} +``` + +--- + +## 7. Миграции + +### Последовательность миграций: + +1. **Добавить поля баланса в Customer**: + ```bash + python manage.py makemigrations customers --name add_account_balance_fields + ``` + +2. **Создать модель AccountTransaction**: + ```bash + python manage.py makemigrations customers --name create_account_transaction_model + ``` + +3. **Создать индексы и ограничения**: + ```bash + python manage.py makemigrations customers --name add_balance_constraints + ``` + +4. **Инициализация данных** (data migration): + ```python + def initialize_customer_balances(apps, schema_editor): + Customer = apps.get_model('customers', 'Customer') + Customer.objects.all().update( + account_balance=0, + available_balance=0, + reserved_balance=0, + allow_negative_balance=False, + negative_balance_limit=0 + ) + ``` + +5. **Добавить способ оплаты**: + ```bash + python manage.py create_payment_methods + ``` + +--- + +## 8. Обеспечение целостности данных + +### 8.1 Транзакции и блокировки + +- Все операции в `@transaction.atomic` +- Использование `select_for_update()` для блокировки записи клиента +- Проверка статуса транзакции перед обработкой + +### 8.2 Ограничения БД + +```python +# В миграции +models.CheckConstraint( + check=models.Q(reserved_balance__gte=0), + name='reserved_balance_non_negative' +) + +models.CheckConstraint( + check=models.Q(account_balance__gte=models.F('reserved_balance') * -1), + name='available_balance_consistency' +) +``` + +### 8.3 Предотвращение дублирования + +- Проверка `status='active'` перед обработкой резервирования +- Связь `related_transaction` для отслеживания цепочки операций +- Валидация перед созданием транзакции + +--- + +## 9. Типы транзакций: Подробное описание + +### DEPOSIT (Пополнение) +- **Когда**: Администратор вручную пополняет счет +- **Эффект**: `account_balance ↑`, `available_balance ↑` +- **Статус**: `completed` + +### AUTO_DEPOSIT (Авто-пополнение) +- **Когда**: `order.amount_paid > order.total_amount` +- **Эффект**: `account_balance ↑`, `available_balance ↑` +- **Статус**: `completed` + +### RESERVATION (Резервирование) +- **Когда**: Создание заказа с оплатой со счета +- **Эффект**: `available_balance ↓`, `reserved_balance ↑` +- **Статус**: `active` → меняется при charge/release + +### CHARGE (Списание) +- **Когда**: Заказ выполнен (`is_positive_end=True`) +- **Эффект**: `account_balance ↓`, `reserved_balance ↓` +- **Статус**: `completed` + +### RESERVATION_RELEASE (Снятие резерва) +- **Когда**: Заказ отменен (`is_negative_end=True`) +- **Эффект**: `available_balance ↑`, `reserved_balance ↓` +- **Статус**: `completed` + +### REFUND (Возврат) +- **Когда**: Администратор принимает решение о возврате +- **Эффект**: `account_balance ↑`, `available_balance ↑` +- **Статус**: `completed` + +### ADJUSTMENT (Корректировка) +- **Когда**: Ручная корректировка администратором +- **Эффект**: `account_balance ±`, `available_balance ±` +- **Статус**: `completed` + +--- + +## 10. Сценарии использования + +### Сценарий 1: Заказ с полной оплатой со счета + +1. Клиент создает заказ на 540 руб. +2. На балансе 1000 руб. +3. Выбирается способ оплаты "С баланса счета" +4. **Создается RESERVATION** на 540 руб.: `available_balance: 1000→460`, `reserved_balance: 0→540` +5. При выполнении заказа создается **CHARGE**: `account_balance: 1000→460`, `reserved_balance: 540→0` + +### Сценарий 2: Смешанная оплата + +1. Заказ на 540 руб. +2. На балансе 300 руб. +3. Создается Payment со счета на 300 руб. → **RESERVATION** 300 руб. +4. Создается Payment наличными на 240 руб. +5. При выполнении → **CHARGE** 300 руб. со счета + +### Сценарий 3: Переплата с авто-пополнением + +1. Заказ на 540 руб. +2. Клиент платит наличными 1000 руб. +3. `order.amount_paid = 1000`, `order.total_amount = 540` +4. Система создает **AUTO_DEPOSIT** на 460 руб. +5. Баланс клиента увеличивается на 460 руб. + +### Сценарий 4: Отмена заказа + +1. Заказ на 540 руб. с резервированием +2. `reserved_balance = 540`, `available_balance = 460` +3. Заказ меняет статус на "Отменен" (`is_negative_end=True`) +4. Сигнал создает **RESERVATION_RELEASE** +5. `available_balance: 460→1000`, `reserved_balance: 540→0` + +### Сценарий 5: Кредит доверенного клиента + +1. У клиента баланс 0 руб., но `allow_negative_balance=True` +2. Заказ на 540 руб. +3. Создается **RESERVATION** на 540 руб. +4. `account_balance: 0→0`, `available_balance: 0→-540`, `reserved_balance: 0→540` +5. При выполнении **CHARGE**: `account_balance: 0→-540` + +--- + +## 11. Тестирование + +### Unit Tests + +**Файл**: `myproject/customers/tests/test_account_balance_service.py` + +Тесты: +- `test_deposit_increases_balance` +- `test_reserve_decreases_available` +- `test_charge_decreases_account_balance` +- `test_release_increases_available` +- `test_overpayment_creates_auto_deposit` +- `test_negative_balance_validation` +- `test_credit_limit_enforcement` +- `test_concurrent_operations` + +### Integration Tests + +**Файл**: `myproject/orders/tests/test_order_with_account_balance.py` + +Тесты: +- `test_order_with_account_payment` +- `test_mixed_payment_scenario` +- `test_order_completion_charges_balance` +- `test_order_cancellation_releases_reservation` +- `test_overpayment_auto_deposit` + +--- + +## 12. Критические файлы для реализации + +1. **`myproject/customers/models.py`** + - Добавить поля баланса в Customer + - Создать модель AccountTransaction + +2. **`myproject/customers/services/account_balance_service.py`** (НОВЫЙ) + - Все методы управления балансом + +3. **`myproject/orders/models/payment.py`** + - Модифицировать `save()` для обработки оплаты со счета + +4. **`myproject/orders/signals.py`** (НОВЫЙ) + - Обработка изменения статуса заказа + +5. **`myproject/customers/admin.py`** + - Расширить CustomerAdmin + - Создать AccountTransactionAdmin + +6. **`myproject/customers/admin_views.py`** (НОВЫЙ) + - Views для пополнения/возврата/корректировки + +7. **`myproject/customers/forms.py`** (НОВЫЙ) + - Формы для операций с балансом + +8. **`myproject/orders/management/commands/create_payment_methods.py`** + - Добавить способ оплаты 'account_balance' + +9. **`myproject/orders/apps.py`** + - Подключить сигналы + +--- + +## 13. Последовательность реализации + +### Фаза 1: Модели и миграции (основа) +1. Добавить поля в Customer +2. Создать AccountTransaction +3. Создать миграции +4. Инициализировать данные + +### Фаза 2: Бизнес-логика (ядро) +1. Создать AccountBalanceService со всеми методами +2. Покрыть unit-тестами + +### Фаза 3: Интеграция с заказами (связывание) +1. Модифицировать Payment.save() +2. Создать signals.py +3. Добавить способ оплаты +4. Покрыть integration-тестами + +### Фаза 4: Административный интерфейс (управление) +1. Расширить CustomerAdmin +2. Создать AccountTransactionAdmin +3. Создать формы и views для операций +4. Настроить URLs + +### Фаза 5: UI/UX улучшения (удобство) +1. Отображение баланса в форме заказа +2. JS-валидация при оплате +3. Виджеты истории транзакций + +### Фаза 6: Тестирование и документация (качество) +1. Полное покрытие тестами +2. Ручное тестирование сценариев +3. Документация для администраторов + +--- + +## 14. Безопасность и права доступа + +- Только `staff_member_required` для admin views +- Транзакции нельзя удалять (`has_delete_permission = False`) +- Транзакции нельзя создавать вручную (`has_add_permission = False`) +- Все операции требуют `created_by` (аудит) +- Mandatory `description` для adjustment + +--- + +## Заключение + +Данная архитектура обеспечивает: +- ✅ Полную историю операций (аудит) +- ✅ Атомарность операций (транзакции БД) +- ✅ Защиту от race conditions (блокировки) +- ✅ Гибкость (смешанная оплата, кредит) +- ✅ Интеграцию с существующей системой +- ✅ Простоту управления (admin interface) +- ✅ Безопасность (только администраторы) + +Решение готово к production-использованию после прохождения всех фаз тестирования. diff --git a/test_simple.py b/test_simple.py new file mode 100644 index 0000000..2fb64ff --- /dev/null +++ b/test_simple.py @@ -0,0 +1,2 @@ +print +Testing wallet