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

- Добавлено поле 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

@@ -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(
'<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):
"""Делаем все поля 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(
'<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="Общая сумма покупок"
)
# 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}"

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>
<td>{{ customer.total_spent|floatformat:2 }} руб.</td>
</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>
<th>Заметки:</th>
<td>{{ customer.notes|default:"Нет" }}</td>

View File

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