Реализация системы кошелька клиента для переплат
- Добавлено поле 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:
18
myproject/create_wallet_payment_method.py
Normal file
18
myproject/create_wallet_payment_method.py
Normal 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'")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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="Общая сумма покупок"
|
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}"
|
||||||
|
|
||||||
|
|||||||
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>
|
<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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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': 'Наличными',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
73
myproject/test_wallet_system.py
Normal file
73
myproject/test_wallet_system.py
Normal 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
771
nested-singing-rainbow.md
Normal 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
2
test_simple.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
print
|
||||||
|
Testing wallet
|
||||||
Reference in New Issue
Block a user