Реализация системы кошелька клиента для переплат

- Добавлено поле wallet_balance в модель Customer
- Создана модель WalletTransaction для истории операций
- Реализован сервис WalletService с методами:
  * add_overpayment - автоматическое зачисление переплаты
  * pay_with_wallet - оплата заказа из кошелька
  * adjust_balance - ручная корректировка баланса
- Интеграция с Payment.save() для автоматической обработки переплат
- UI для оплаты из кошелька в деталях заказа
- Отображение баланса и долга на странице клиента
- Админка с inline транзакций и запретом ручного создания
- Добавлен способ оплаты account_balance
- Миграция 0004 для customers приложения
This commit is contained in:
2025-11-26 14:47:11 +03:00
parent 0653ec0545
commit 5ead7fdd2e
16 changed files with 1401 additions and 3 deletions

View File

@@ -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'")

View File

@@ -1,6 +1,7 @@
from django.contrib import admin from django.contrib import admin
from django.db import models 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): class IsSystemCustomerFilter(admin.SimpleListFilter):
@@ -28,6 +29,7 @@ class CustomerAdmin(admin.ModelAdmin):
'full_name', 'full_name',
'email', 'email',
'phone', 'phone',
'wallet_balance_display',
'total_spent', 'total_spent',
'is_system_customer', 'is_system_customer',
'created_at' 'created_at'
@@ -43,12 +45,15 @@ class CustomerAdmin(admin.ModelAdmin):
) )
date_hierarchy = 'created_at' date_hierarchy = 'created_at'
ordering = ('-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 = ( fieldsets = (
('Основная информация', { ('Основная информация', {
'fields': ('name', 'email', 'phone', 'is_system_customer') 'fields': ('name', 'email', 'phone', 'is_system_customer')
}), }),
('Кошелёк', {
'fields': ('wallet_balance',),
}),
('Статистика покупок', { ('Статистика покупок', {
'fields': ('total_spent',), 'fields': ('total_spent',),
'classes': ('collapse',) 'classes': ('collapse',)
@@ -62,11 +67,22 @@ class CustomerAdmin(admin.ModelAdmin):
}), }),
) )
def wallet_balance_display(self, obj):
"""Отображение баланса кошелька с цветом"""
if obj.wallet_balance > 0:
return format_html(
'<span style="color: green; font-weight: bold;">{} руб.</span>',
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): def get_readonly_fields(self, request, obj=None):
"""Делаем все поля read-only для системного клиента""" """Делаем все поля read-only для системного клиента"""
if obj and obj.is_system_customer: 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 return self.readonly_fields
def has_delete_permission(self, request, obj=None): def has_delete_permission(self, request, obj=None):
@@ -85,3 +101,56 @@ class CustomerAdmin(admin.ModelAdmin):
from django.contrib import messages from django.contrib import messages
messages.warning(request, 'Это системный клиент. Редактирование запрещено для обеспечения корректной работы системы.') messages.warning(request, 'Это системный клиент. Редактирование запрещено для обеспечения корректной работы системы.')
return super().changeform_view(request, object_id, form_url, extra_context) 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(
'<span style="color: green; font-weight: bold;">+{} руб.</span>',
obj.amount
)
elif obj.transaction_type == 'spend':
return format_html(
'<span style="color: red; font-weight: bold;">-{} руб.</span>',
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

View File

@@ -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')],
},
),
]

View File

@@ -32,6 +32,15 @@ class Customer(models.Model):
verbose_name="Общая сумма покупок" 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 # System customer flag
is_system_customer = models.BooleanField( is_system_customer = models.BooleanField(
default=False, default=False,
@@ -207,3 +216,74 @@ class Customer(models.Model):
) )
return customer, created 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}"

View File

@@ -0,0 +1,3 @@
"""
Сервисы для работы с клиентами.
"""

View File

@@ -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

View File

@@ -42,6 +42,27 @@
<th>Сумма покупок:</th> <th>Сумма покупок:</th>
<td>{{ customer.total_spent|floatformat:2 }} руб.</td> <td>{{ customer.total_spent|floatformat:2 }} руб.</td>
</tr> </tr>
<tr>
<th>Баланс кошелька:</th>
<td>
{% if customer.wallet_balance > 0 %}
<span class="text-success fw-bold">{{ customer.wallet_balance|floatformat:2 }} руб.</span>
{% else %}
{{ customer.wallet_balance|floatformat:2 }} руб.
{% endif %}
</td>
</tr>
<tr>
<th>Общий долг по активным заказам:</th>
<td>
{% if total_debt > 0 %}
<span class="text-danger fw-bold">{{ total_debt|floatformat:2 }} руб.</span>
<small class="text-muted">(Кол-во заказов: {{ active_orders_count }})</small>
{% else %}
<span class="text-success">0.00 руб.</span>
{% endif %}
</td>
</tr>
<tr> <tr>
<th>Заметки:</th> <th>Заметки:</th>
<td>{{ customer.notes|default:"Нет" }}</td> <td>{{ customer.notes|default:"Нет" }}</td>

View File

@@ -85,8 +85,14 @@ def customer_detail(request, pk):
if customer.is_system_customer: if customer.is_system_customer:
return render(request, 'customers/customer_system.html') 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 = { context = {
'customer': customer, 'customer': customer,
'total_debt': total_debt,
'active_orders_count': active_orders.count(),
} }
return render(request, 'customers/customer_detail.html', context) return render(request, 'customers/customer_detail.html', context)

View File

@@ -8,6 +8,13 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
payment_methods = [ payment_methods = [
{
'code': 'account_balance',
'name': 'С баланса счёта',
'description': 'Оплата из кошелька клиента',
'is_system': True,
'order': 0
},
{ {
'code': 'cash', 'code': 'cash',
'name': 'Наличными', 'name': 'Наличными',

View File

@@ -142,3 +142,11 @@ class Payment(models.Model):
# Пересчитываем общую сумму оплаты в заказе # Пересчитываем общую сумму оплаты в заказе
self.order.amount_paid = sum(p.amount for p in self.order.payments.all()) self.order.amount_paid = sum(p.amount for p in self.order.payments.all())
self.order.update_payment_status() 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

View File

@@ -236,6 +236,64 @@
<!-- Правая колонка --> <!-- Правая колонка -->
<div class="col-md-4"> <div class="col-md-4">
<!-- Кошелёк клиента -->
{% if order.customer and order.customer.wallet_balance > 0 and order.amount_due > 0 %}
<div class="card mb-3 border-success">
<div class="card-header bg-success text-white">
<h5 class="mb-0">Кошелёк клиента</h5>
</div>
<div class="card-body">
<p class="mb-2">
<strong>Баланс:</strong>
<span class="h5 text-success">{{ order.customer.wallet_balance|floatformat:2 }} руб.</span>
</p>
<p class="text-muted small">Можно использовать для оплаты этого заказа</p>
<!-- Кнопка "Применить максимум" -->
<form method="post" action="{% url 'orders:apply-wallet' order.pk %}" class="mb-2">
{% csrf_token %}
<input type="hidden" name="wallet_amount" value="{% if order.customer.wallet_balance < order.amount_due %}{{ order.customer.wallet_balance|floatformat:2 }}{% else %}{{ order.amount_due|floatformat:2 }}{% endif %}">
<button type="submit" class="btn btn-success w-100">
<i class="bi bi-wallet2"></i> Применить максимум
</button>
</form>
<!-- Ручной ввод суммы -->
<form method="post" action="{% url 'orders:apply-wallet' order.pk %}">
{% csrf_token %}
<div class="input-group">
<input
type="number"
step="0.01"
min="0"
max="{% if order.customer.wallet_balance < order.amount_due %}{{ order.customer.wallet_balance|floatformat:2 }}{% else %}{{ order.amount_due|floatformat:2 }}{% endif %}"
name="wallet_amount"
class="form-control"
placeholder="Сумма"
>
<button type="submit" class="btn btn-outline-success">
Оплатить
</button>
</div>
<small class="text-muted">Введите сумму для списания из кошелька</small>
</form>
</div>
</div>
{% elif order.customer and order.customer.wallet_balance > 0 %}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Кошелёк клиента</h5>
</div>
<div class="card-body">
<p class="mb-0">
<strong>Баланс:</strong>
<span class="h5 text-success">{{ order.customer.wallet_balance|floatformat:2 }} руб.</span>
</p>
<small class="text-muted">Заказ уже оплачен полностью</small>
</div>
</div>
{% endif %}
<!-- Оплата --> <!-- Оплата -->
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"> <div class="card-header">

View File

@@ -16,6 +16,9 @@ urlpatterns = [
path('create-draft/', views.create_draft_from_form, name='order-create-draft'), 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'), path('api/customer-address-history/', views.get_customer_address_history, name='api-customer-address-history'),
# Wallet payment
path('<int:pk>/apply-wallet/', views.apply_wallet_payment, name='apply-wallet'),
# Order Status Management URLs # Order Status Management URLs
path('statuses/', views.order_status_list, name='status_list'), path('statuses/', views.order_status_list, name='status_list'),
path('statuses/create/', views.order_status_create, name='status_create'), path('statuses/create/', views.order_status_create, name='status_create'),

View File

@@ -7,6 +7,7 @@ from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from decimal import Decimal
from .models import Order, OrderItem, Address, OrderStatus from .models import Order, OrderItem, Address, OrderStatus
from .forms import OrderForm, OrderItemFormSet, OrderStatusForm, PaymentFormSet from .forms import OrderForm, OrderItemFormSet, OrderStatusForm, PaymentFormSet
from .filters import OrderFilter from .filters import OrderFilter
@@ -604,3 +605,47 @@ def order_status_delete(request, pk):
# === ВРЕМЕННЫЕ КОМПЛЕКТЫ === # === ВРЕМЕННЫЕ КОМПЛЕКТЫ ===
# УДАЛЕНО: Логика создания временных комплектов перенесена в products.services.kit_service # УДАЛЕНО: Логика создания временных комплектов перенесена в products.services.kit_service
# Используйте API endpoint: products:api-temporary-kit-create # Используйте 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)

View File

@@ -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")

771
nested-singing-rainbow.md Normal file
View File

@@ -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 %}
<div class="alert alert-info">
<h5>Баланс клиента</h5>
<ul>
<li>Общий баланс: <strong>{{ order.customer.account_balance }} руб.</strong></li>
<li>Доступно: <strong>{{ order.customer.available_balance }} руб.</strong></li>
<li>Зарезервировано: <strong>{{ order.customer.reserved_balance }} руб.</strong></li>
</ul>
</div>
{% 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-использованию после прохождения всех фаз тестирования.

2
test_simple.py Normal file
View File

@@ -0,0 +1,2 @@
print
Testing wallet