Реализация системы кошелька клиента для переплат
- Добавлено поле 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:
@@ -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
|
||||
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
|
||||
|
||||
3
myproject/customers/services/__init__.py
Normal file
3
myproject/customers/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Сервисы для работы с клиентами.
|
||||
"""
|
||||
193
myproject/customers/services/wallet_service.py
Normal file
193
myproject/customers/services/wallet_service.py
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user