Рефакторинг системы кошелька клиентов

Основные изменения:
- Переход от денормализованного поля wallet_balance к вычисляемому балансу
- Баланс теперь вычисляется как SUM(signed_amount) транзакций
- Добавлено кеширование баланса для производительности (5 минут)
- Новая модель WalletTransaction с полем signed_amount (может быть +/-)
- WalletService для всех операций с кошельком (deposit, spend, adjustment)
- Защита от отрицательного баланса и race conditions через select_for_update
- Добавлен balance_after в каждую транзакцию для аудита
- Обновлены миграции для переноса данных из старой схемы

Улучшения безопасности:
- Атомарные транзакции для всех операций с балансом
- Блокировка строк при модификации баланса
- Валидация недостаточности средств
- Обязательное описание для корректировок баланса

UI/UX изменения:
- Обновлён вывод баланса кошелька в деталях клиента
- Добавлена история транзакций с типами и описаниями
- Цветовая индикация положительных транзакций (зелёный)

Техническая документация:
- Добавлены docstrings для всех методов WalletService
- Комментарии к критичным участкам кода
- Примеры использования в docstrings
This commit is contained in:
2025-12-28 00:02:09 +03:00
parent 65b3055755
commit b1855cc9f0
9 changed files with 800 additions and 170 deletions

View File

@@ -1,7 +1,7 @@
from django.contrib import admin
from django.db import models
from django.utils.html import format_html
from .models import Customer, WalletTransaction
from .models import Customer, WalletTransaction, ContactChannel
class IsSystemCustomerFilter(admin.SimpleListFilter):
@@ -44,14 +44,14 @@ class CustomerAdmin(admin.ModelAdmin):
)
date_hierarchy = 'created_at'
ordering = ('-created_at',)
readonly_fields = ('created_at', 'updated_at', 'is_system_customer', 'wallet_balance')
readonly_fields = ('created_at', 'updated_at', 'is_system_customer', 'wallet_balance_display')
fieldsets = (
('Основная информация', {
'fields': ('name', 'email', 'phone', 'is_system_customer')
}),
('Кошелёк', {
'fields': ('wallet_balance',),
'fields': ('wallet_balance_display',),
}),
('Заметки', {
'fields': ('notes',)
@@ -64,20 +64,20 @@ class CustomerAdmin(admin.ModelAdmin):
def wallet_balance_display(self, obj):
"""Отображение баланса кошелька с цветом"""
if obj.wallet_balance > 0:
balance = obj.wallet_balance
if balance > 0:
return format_html(
'<span style="color: green; font-weight: bold;">{} руб.</span>',
obj.wallet_balance
balance
)
return f'{obj.wallet_balance} руб.'
return f'{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', 'is_system_customer', 'wallet_balance', 'notes', 'created_at', 'updated_at']
return ['name', 'email', 'phone', 'is_system_customer', 'wallet_balance_display', 'notes', 'created_at', 'updated_at']
return self.readonly_fields
def has_delete_permission(self, request, obj=None):
@@ -98,14 +98,20 @@ class CustomerAdmin(admin.ModelAdmin):
return super().changeform_view(request, object_id, form_url, extra_context)
class ContactChannelInline(admin.TabularInline):
"""Inline для управления каналами связи клиента"""
model = ContactChannel
extra = 1
fields = ('channel_type', 'value', 'is_primary', 'notes')
class WalletTransactionInline(admin.TabularInline):
"""
line для отображения транзакций кошелька"""
"""Inline для отображения транзакций кошелька"""
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')
readonly_fields = ('created_at', 'transaction_type', 'signed_amount', 'balance_after', 'order', 'description', 'created_by')
fields = ('created_at', 'transaction_type', 'signed_amount', 'balance_after', 'order', 'description', 'created_by')
ordering = ('-created_at',)
def has_add_permission(self, request, obj=None):
@@ -114,32 +120,33 @@ line для отображения транзакций кошелька"""
# Добавляем inline в CustomerAdmin
CustomerAdmin.inlines = [WalletTransactionInline]
CustomerAdmin.inlines = [ContactChannelInline, 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')
list_display = ('created_at', 'customer', 'transaction_type', 'amount_display', 'balance_after', 'order', 'created_by')
list_filter = ('transaction_type', 'balance_category', 'created_at')
search_fields = ('customer__name', 'customer__email', 'customer__phone', 'description')
readonly_fields = ('customer', 'transaction_type', 'amount', 'order', 'description', 'created_at', 'created_by')
readonly_fields = ('customer', 'transaction_type', 'signed_amount', 'balance_category', 'balance_after', 'order', 'description', 'created_at', 'created_by')
date_hierarchy = 'created_at'
ordering = ('-created_at',)
def amount_display(self, obj):
"""Отображение суммы с цветом"""
if obj.transaction_type == 'deposit':
amount = obj.signed_amount
if amount > 0:
return format_html(
'<span style="color: green; font-weight: bold;">+{} руб.</span>',
obj.amount
amount
)
elif obj.transaction_type == 'spend':
elif amount < 0:
return format_html(
'<span style="color: red; font-weight: bold;">-{} руб.</span>',
obj.amount
'<span style="color: red; font-weight: bold;">{} руб.</span>',
amount
)
return f'{obj.amount} руб.'
return f'{amount} руб.'
amount_display.short_description = 'Сумма'
def has_add_permission(self, request):

View File

@@ -2,7 +2,7 @@ from django import forms
from django.core.exceptions import ValidationError
from phonenumber_field.formfields import PhoneNumberField
from phonenumber_field.widgets import PhoneNumberPrefixWidget
from .models import Customer
from .models import Customer, ContactChannel
class CustomerForm(forms.ModelForm):
phone = PhoneNumberField(
@@ -35,43 +35,17 @@ class CustomerForm(forms.ModelForm):
field.widget.attrs.update({'class': 'form-control'})
def clean_email(self):
"""Проверяет уникальность email при создании/редактировании"""
"""Нормализует пустые значения email в None"""
email = self.cleaned_data.get('email')
# Нормализуем пустые значения в None (Django best practice для nullable полей)
if not email:
return None
# Проверяем уникальность
queryset = Customer.objects.filter(email=email)
# При редактировании исключаем текущий экземпляр
if self.instance and self.instance.pk:
queryset = queryset.exclude(pk=self.instance.pk)
if queryset.exists():
raise ValidationError('Клиент с таким email уже существует.')
return email
def clean_phone(self):
"""Проверяет уникальность телефона при создании/редактировании"""
"""Нормализует пустые значения телефона в None"""
phone = self.cleaned_data.get('phone')
# Нормализуем пустые значения в None (Django best practice для nullable полей)
if not phone:
return None
# Проверяем уникальность
queryset = Customer.objects.filter(phone=phone)
# При редактировании исключаем текущий экземпляр
if self.instance and self.instance.pk:
queryset = queryset.exclude(pk=self.instance.pk)
if queryset.exists():
raise ValidationError('Клиент с таким номером телефона уже существует.')
return phone
def clean(self):
@@ -85,4 +59,35 @@ class CustomerForm(forms.ModelForm):
'Он необходим для корректной работы системы и создается автоматически.'
)
return cleaned_data
return cleaned_data
class ContactChannelForm(forms.ModelForm):
"""Форма для добавления/редактирования канала связи"""
class Meta:
model = ContactChannel
fields = ['channel_type', 'value', 'is_primary', 'notes']
widgets = {
'channel_type': forms.Select(attrs={'class': 'form-select'}),
'value': forms.TextInput(attrs={'class': 'form-control', 'placeholder': '@username, номер и т.д.'}),
'notes': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Личный аккаунт, рабочий...'}),
'is_primary': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
def clean_value(self):
value = self.cleaned_data.get('value', '').strip()
channel_type = self.cleaned_data.get('channel_type')
if not value:
raise ValidationError('Значение не может быть пустым')
# Проверка уникальности комбинации channel_type + value
qs = ContactChannel.objects.filter(channel_type=channel_type, value=value)
if self.instance.pk:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
type_display = dict(ContactChannel.CHANNEL_TYPES).get(channel_type, channel_type)
raise ValidationError(f'Такой {type_display} уже существует у другого клиента')
return value

View File

@@ -0,0 +1,44 @@
# Generated by Django 5.0.10 on 2025-12-27 19:32
import django.db.models.deletion
import phonenumber_field.modelfields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('customers', '0002_initial'),
]
operations = [
migrations.AlterField(
model_name='customer',
name='email',
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email'),
),
migrations.AlterField(
model_name='customer',
name='phone',
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат', max_length=128, null=True, region=None, verbose_name='Телефон'),
),
migrations.CreateModel(
name='ContactChannel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('channel_type', models.CharField(choices=[('phone', 'Телефон'), ('email', 'Email'), ('telegram', 'Telegram'), ('instagram', 'Instagram'), ('whatsapp', 'WhatsApp'), ('viber', 'Viber'), ('vk', 'ВКонтакте'), ('facebook', 'Facebook'), ('other', 'Другое')], max_length=20, verbose_name='Тип канала')),
('value', models.CharField(help_text='Username, номер телефона, email и т.д.', max_length=255, verbose_name='Значение')),
('is_primary', models.BooleanField(default=False, verbose_name='Основной')),
('notes', models.CharField(blank=True, max_length=255, verbose_name='Примечание')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_channels', to='customers.customer', verbose_name='Клиент')),
],
options={
'verbose_name': 'Канал связи',
'verbose_name_plural': 'Каналы связи',
'ordering': ['-is_primary', 'channel_type'],
'indexes': [models.Index(fields=['channel_type', 'value'], name='customers_c_channel_179e89_idx'), models.Index(fields=['customer'], name='customers_c_custome_f14e0e_idx')],
'unique_together': {('channel_type', 'value')},
},
),
]

View File

@@ -0,0 +1,146 @@
# Generated by Django 5.0.10 on 2025-12-27 20:24
import django.db.models.deletion
from decimal import Decimal
from django.conf import settings
from django.db import migrations, models
def populate_signed_amount(apps, schema_editor):
"""
Заполняем signed_amount на основе старого amount и типа транзакции.
spend -> отрицательная сумма
deposit/adjustment -> положительная сумма
"""
WalletTransaction = apps.get_model('customers', 'WalletTransaction')
for txn in WalletTransaction.objects.all():
if txn.transaction_type == 'spend':
txn.signed_amount = -abs(txn.amount)
else:
# deposit, adjustment - положительные
txn.signed_amount = abs(txn.amount)
txn.save(update_fields=['signed_amount'])
def calculate_balance_after(apps, schema_editor):
"""
Вычисляем balance_after для всех существующих транзакций.
"""
Customer = apps.get_model('customers', 'Customer')
WalletTransaction = apps.get_model('customers', 'WalletTransaction')
for customer in Customer.objects.all():
running_balance = Decimal('0')
# Обрабатываем транзакции в хронологическом порядке
for txn in WalletTransaction.objects.filter(customer=customer).order_by('created_at'):
running_balance += txn.signed_amount or Decimal('0')
txn.balance_after = running_balance
txn.save(update_fields=['balance_after'])
def reverse_populate(apps, schema_editor):
"""Обратная операция - ничего не делаем."""
pass
class Migration(migrations.Migration):
dependencies = [
('customers', '0003_alter_customer_email_alter_customer_phone_and_more'),
('orders', '0008_historicalorder_needs_delivery_photo_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
# 1. Добавляем новые поля (signed_amount временно nullable)
migrations.AddField(
model_name='wallettransaction',
name='signed_amount',
field=models.DecimalField(
decimal_places=2,
max_digits=10,
null=True, # Временно nullable для миграции данных
help_text='Положительная для пополнений, отрицательная для списаний',
verbose_name='Сумма'
),
),
migrations.AddField(
model_name='wallettransaction',
name='balance_after',
field=models.DecimalField(
blank=True,
decimal_places=2,
help_text='Баланс кошелька после применения этой транзакции',
max_digits=10,
null=True,
verbose_name='Баланс после'
),
),
migrations.AddField(
model_name='wallettransaction',
name='balance_category',
field=models.CharField(
choices=[('money', 'Реальные деньги')],
default='money',
max_length=20,
verbose_name='Категория'
),
),
# 2. Копируем данные из amount в signed_amount
migrations.RunPython(populate_signed_amount, reverse_populate),
# 3. Вычисляем balance_after
migrations.RunPython(calculate_balance_after, reverse_populate),
# 4. Делаем signed_amount NOT NULL
migrations.AlterField(
model_name='wallettransaction',
name='signed_amount',
field=models.DecimalField(
decimal_places=2,
max_digits=10,
help_text='Положительная для пополнений, отрицательная для списаний',
verbose_name='Сумма'
),
),
# 5. Удаляем старое поле amount
migrations.RemoveField(
model_name='wallettransaction',
name='amount',
),
# 6. Удаляем wallet_balance из Customer
migrations.RemoveField(
model_name='customer',
name='wallet_balance',
),
# 7. Обновляем связь с Order (добавляем related_name)
migrations.AlterField(
model_name='wallettransaction',
name='order',
field=models.ForeignKey(
blank=True,
help_text='Заказ, к которому относится транзакция (если применимо)',
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='wallet_transactions',
to='orders.order',
verbose_name='Заказ'
),
),
# 8. Добавляем индексы
migrations.AddIndex(
model_name='wallettransaction',
index=models.Index(fields=['balance_category'], name='customers_w_balance_81f0a9_idx'),
),
migrations.AddIndex(
model_name='wallettransaction',
index=models.Index(fields=['customer', 'balance_category'], name='customers_w_custome_060570_idx'),
),
]

View File

@@ -1,6 +1,11 @@
from decimal import Decimal
import phonenumbers
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Sum, Value, DecimalField as DjDecimalField
from django.db.models.functions import Coalesce
from phonenumber_field.modelfields import PhoneNumberField
@@ -11,13 +16,12 @@ class Customer(models.Model):
# Name field that is not required to be unique
name = models.CharField(max_length=200, blank=True, verbose_name="Имя")
email = models.EmailField(blank=True, null=True, unique=True, verbose_name="Email")
email = models.EmailField(blank=True, null=True, verbose_name="Email")
# Phone with validation using django-phonenumber-field
phone = PhoneNumberField(
blank=True,
null=True,
unique=True,
blank=True,
null=True,
verbose_name="Телефон",
help_text="Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат"
)
@@ -25,15 +29,6 @@ class Customer(models.Model):
# Temporary field to store raw phone number during initialization
_raw_phone = None
# 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,
@@ -81,20 +76,6 @@ class Customer(models.Model):
"""Полное имя клиента"""
return self.name
def validate_unique(self, exclude=None):
"""Переопределение для корректной проверки уникальности телефона при обновлениях"""
# Снова нормализуем номер телефона перед проверкой уникальности
if self.phone:
# Проверяем существующих клиентов с таким же телефоном (исключая текущий экземпляр при обновлении)
existing = Customer.objects.filter(phone=self.phone)
if self.pk:
existing = existing.exclude(pk=self.pk)
if existing.exists():
raise ValidationError({'phone': 'Пользователь с таким номером телефона уже существует.'})
# Вызываем родительский validate_unique для обработки других проверок
super().validate_unique(exclude=exclude)
def clean_phone(self):
"""Пользовательская очистка поля телефона для нормализации перед валидацией."""
if self.phone:
@@ -252,6 +233,68 @@ class Customer(models.Model):
"""
return self.wallet_transactions.all()
# ========== МЕТОДЫ ВЫЧИСЛЯЕМОГО БАЛАНСА КОШЕЛЬКА ==========
def get_wallet_balance(self, category='money', use_cache=True):
"""
Вычисляет баланс кошелька как SUM(signed_amount) транзакций.
Args:
category: 'money' или 'bonus' (для будущей бонусной системы)
use_cache: использовать кеш (по умолчанию True)
Returns:
Decimal: текущий баланс
"""
if not self.pk:
return Decimal('0')
cache_key = f'wallet_balance:{self.pk}:{category}'
if use_cache:
cached = cache.get(cache_key)
if cached is not None:
return Decimal(str(cached))
result = self.wallet_transactions.filter(
balance_category=category
).aggregate(
total=Coalesce(
Sum('signed_amount'),
Value(0),
output_field=DjDecimalField()
)
)
balance = result['total'] or Decimal('0')
if use_cache:
cache.set(cache_key, str(balance), timeout=300) # 5 минут
return balance
@property
def wallet_balance(self):
"""
Баланс кошелька (реальные деньги).
Обратная совместимость: используется в templates и существующем коде.
Returns:
Decimal: текущий баланс кошелька
"""
return self.get_wallet_balance(category='money')
def invalidate_wallet_cache(self, category='money'):
"""Сбросить кеш баланса кошелька."""
cache_key = f'wallet_balance:{self.pk}:{category}'
cache.delete(cache_key)
# Для будущей бонусной системы:
# @property
# def bonus_balance(self):
# """Баланс бонусных баллов."""
# return self.get_wallet_balance(category='bonus')
def get_successful_orders_total(self, start_date=None, end_date=None):
"""
Получить сумму успешных заказов за указанный период.
@@ -303,15 +346,86 @@ class Customer(models.Model):
return self.get_successful_orders_total(start_date=year_ago, end_date=today)
class ContactChannel(models.Model):
"""Канал связи с клиентом (телефон, email, соцсети)"""
CHANNEL_TYPES = [
('phone', 'Телефон'),
('email', 'Email'),
('telegram', 'Telegram'),
('instagram', 'Instagram'),
('whatsapp', 'WhatsApp'),
('viber', 'Viber'),
('vk', 'ВКонтакте'),
('facebook', 'Facebook'),
('other', 'Другое'),
]
customer = models.ForeignKey(
'Customer',
on_delete=models.CASCADE,
related_name='contact_channels',
verbose_name="Клиент"
)
channel_type = models.CharField(
max_length=20,
choices=CHANNEL_TYPES,
verbose_name="Тип канала"
)
value = models.CharField(
max_length=255,
verbose_name="Значение",
help_text="Username, номер телефона, email и т.д."
)
is_primary = models.BooleanField(
default=False,
verbose_name="Основной"
)
notes = models.CharField(
max_length=255,
blank=True,
verbose_name="Примечание"
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
class Meta:
unique_together = ['channel_type', 'value']
indexes = [
models.Index(fields=['channel_type', 'value']),
models.Index(fields=['customer']),
]
verbose_name = "Канал связи"
verbose_name_plural = "Каналы связи"
ordering = ['-is_primary', 'channel_type']
def __str__(self):
return f"{self.get_channel_type_display()}: {self.value}"
class WalletTransaction(models.Model):
"""
Транзакция по кошельку клиента.
Хранит историю всех пополнений, списаний и корректировок баланса.
Архитектура: баланс кошелька = SUM(signed_amount) всех транзакций клиента.
Это единственный источник правды о балансе.
"""
# Типы транзакций (расширяемо для будущей бонусной системы)
TRANSACTION_TYPE_CHOICES = [
('deposit', 'Пополнение'),
('spend', 'Списание'),
('adjustment', 'Корректировка'),
# Для будущей бонусной системы:
# ('bonus_accrual', 'Начисление бонусов'),
# ('bonus_spend', 'Списание бонусов'),
# ('cashback', 'Кэшбэк'),
]
# Категории баланса (для разделения "реальные деньги" vs "бонусы")
BALANCE_CATEGORY_CHOICES = [
('money', 'Реальные деньги'),
# ('bonus', 'Бонусные баллы'), # Для будущей реализации
]
customer = models.ForeignKey(
@@ -321,10 +435,12 @@ class WalletTransaction(models.Model):
verbose_name="Клиент"
)
amount = models.DecimalField(
# Знаковая сумма: положительная = приход, отрицательная = расход
signed_amount = models.DecimalField(
max_digits=10,
decimal_places=2,
verbose_name="Сумма"
verbose_name="Сумма",
help_text="Положительная для пополнений, отрицательная для списаний"
)
transaction_type = models.CharField(
@@ -333,11 +449,20 @@ class WalletTransaction(models.Model):
verbose_name="Тип транзакции"
)
# Категория баланса (подготовка к бонусной системе)
balance_category = models.CharField(
max_length=20,
choices=BALANCE_CATEGORY_CHOICES,
default='money',
verbose_name="Категория"
)
order = models.ForeignKey(
'orders.Order',
null=True,
blank=True,
on_delete=models.PROTECT,
related_name='wallet_transactions',
verbose_name="Заказ",
help_text="Заказ, к которому относится транзакция (если применимо)"
)
@@ -360,6 +485,16 @@ class WalletTransaction(models.Model):
verbose_name="Создано пользователем"
)
# Баланс после транзакции (для быстрого аудита и отображения в истории)
balance_after = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
verbose_name="Баланс после",
help_text="Баланс кошелька после применения этой транзакции"
)
class Meta:
verbose_name = "Транзакция кошелька"
verbose_name_plural = "Транзакции кошелька"
@@ -368,8 +503,16 @@ class WalletTransaction(models.Model):
models.Index(fields=['customer', '-created_at']),
models.Index(fields=['transaction_type']),
models.Index(fields=['order']),
models.Index(fields=['balance_category']),
models.Index(fields=['customer', 'balance_category']),
]
def __str__(self):
return f"{self.get_transaction_type_display()} {self.amount} руб. для {self.customer}"
sign = '+' if self.signed_amount >= 0 else ''
return f"{self.get_transaction_type_display()} {sign}{self.signed_amount} руб. для {self.customer}"
@property
def amount(self):
"""Абсолютная сумма (для обратной совместимости)."""
return abs(self.signed_amount)

View File

@@ -1,8 +1,10 @@
"""
Сервис для работы с кошельком клиента.
Обрабатывает пополнения, списания и корректировки баланса.
Все операции создают транзакции в WalletTransaction.
Баланс вычисляется как SUM(signed_amount).
"""
from decimal import Decimal, ROUND_HALF_UP
from django.db import transaction
@@ -20,20 +22,168 @@ def _quantize(value):
class WalletService:
"""
Сервис для управления кошельком клиента.
Все операции атомарны и блокируют запись клиента для избежания race conditions.
Архитектура:
- Баланс = SUM(signed_amount) транзакций (нет денормализованного поля)
- Все операции атомарны с блокировкой строк
- Кеширование баланса для производительности
- Инвалидация кеша при каждой транзакции
"""
@staticmethod
@transaction.atomic
def create_transaction(
customer,
amount,
transaction_type,
category='money',
order=None,
description='',
user=None
):
"""
Создать транзакцию кошелька (базовый метод).
Args:
customer: Customer или customer_id
amount: Decimal - положительная сумма
transaction_type: str - 'deposit', 'spend', 'adjustment'
category: str - 'money' или 'bonus'
order: Order - связанный заказ (опционально)
description: str - описание
user: CustomUser - кто создал
Returns:
WalletTransaction
Raises:
ValueError: если некорректные данные или недостаточно средств
"""
from customers.models import Customer, WalletTransaction
# Получаем и блокируем клиента
if isinstance(customer, int):
customer = Customer.objects.select_for_update().get(pk=customer)
else:
customer = Customer.objects.select_for_update().get(pk=customer.pk)
amount = _quantize(amount)
if amount <= 0:
raise ValueError('Сумма должна быть положительной')
# Определяем знак суммы
if transaction_type == 'spend':
signed_amount = -amount
else:
signed_amount = amount
# Получаем текущий баланс (без кеша для точности)
current_balance = customer.get_wallet_balance(category=category, use_cache=False)
# Проверяем баланс для списания
if signed_amount < 0:
if current_balance + signed_amount < 0:
raise ValueError(
f'Недостаточно средств. Баланс: {current_balance}, '
f'запрошено: {abs(signed_amount)}'
)
# Вычисляем баланс после транзакции
balance_after = current_balance + signed_amount
# Создаём транзакцию
txn = WalletTransaction.objects.create(
customer=customer,
signed_amount=signed_amount,
transaction_type=transaction_type,
balance_category=category,
order=order,
description=description,
created_by=user,
balance_after=balance_after
)
# Инвалидируем кеш
customer.invalidate_wallet_cache(category=category)
return txn
@staticmethod
@transaction.atomic
def create_adjustment(customer, amount, description, user, category='money'):
"""
Корректировка баланса (может быть положительной или отрицательной).
Используется для административных операций:
- Пополнение кошелька
- Списание средств
- Исправление ошибок
Args:
customer: Customer или customer_id
amount: Decimal - сумма (может быть отрицательной)
description: str - обязательное описание
user: CustomUser
category: str - 'money' или 'bonus'
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('Сумма корректировки не может быть нулевой')
# Получаем и блокируем клиента
if isinstance(customer, int):
customer = Customer.objects.select_for_update().get(pk=customer)
else:
customer = Customer.objects.select_for_update().get(pk=customer.pk)
# Получаем текущий баланс
current_balance = customer.get_wallet_balance(category=category, use_cache=False)
# Проверяем, что баланс не уйдёт в минус
if current_balance + amount < 0:
raise ValueError(
f'Корректировка приведёт к отрицательному балансу. '
f'Текущий баланс: {current_balance}, корректировка: {amount}'
)
# Вычисляем баланс после
balance_after = current_balance + amount
# Создаём транзакцию
txn = WalletTransaction.objects.create(
customer=customer,
signed_amount=amount, # Может быть положительной или отрицательной
transaction_type='adjustment',
balance_category=category,
order=None,
description=description,
created_by=user,
balance_after=balance_after
)
# Инвалидируем кеш
customer.invalidate_wallet_cache(category=category)
return txn
@staticmethod
@transaction.atomic
def pay_with_wallet(order, amount, user):
"""
Оплата заказа из кошелька клиента.
Создаёт транзакцию. Списание из кошелька происходит автоматически в Transaction.save().
Args:
order: Заказ для оплаты
amount: Запрашиваемая сумма для списания
user: Пользователь, инициировавший операцию
amount: Запрашиваемая сумма
user: Пользователь
Returns:
Decimal: Фактически списанная сумма или None
@@ -41,26 +191,28 @@ class WalletService:
from customers.models import Customer
from orders.services.transaction_service import TransactionService
# Округляем запрошенную сумму
amount = _quantize(amount)
if amount <= 0:
return None
# Блокируем запись клиента для проверки баланса
# Блокируем клиента для проверки баланса
customer = Customer.objects.select_for_update().get(pk=order.customer_id)
# Остаток к оплате по заказу
# Текущий баланс
wallet_balance = customer.get_wallet_balance(use_cache=False)
# Остаток к оплате
amount_due = order.total_amount - order.amount_paid
# Определяем фактическую сумму списания (минимум из трёх)
usable_amount = min(amount, customer.wallet_balance, amount_due)
# Фактическая сумма (минимум из трёх)
usable_amount = min(amount, wallet_balance, amount_due)
usable_amount = _quantize(usable_amount)
if usable_amount <= 0:
return None
# Создаём транзакцию
# Transaction.save() автоматически спишет из кошелька и создаст WalletTransaction
# Создаём транзакцию платежа
# Transaction.save() вызовет create_wallet_spend()
TransactionService.create_payment(
order=order,
amount=usable_amount,
@@ -76,12 +228,11 @@ class WalletService:
def refund_wallet_payment(order, amount, user):
"""
Возврат средств в кошелёк.
Используется для создания транзакции возврата с кошельком.
Args:
order: Заказ, по которому был платёж
order: Заказ
amount: Сумма возврата
user: Пользователь, инициировавший возврат
user: Пользователь
Returns:
Decimal: Возвращённая сумма
@@ -93,7 +244,7 @@ class WalletService:
return None
# Создаём транзакцию возврата
# Transaction.save() автоматически вернёт в кошелёк и создаст WalletTransaction
# Transaction.save() вызовет create_wallet_deposit()
TransactionService.create_refund(
order=order,
amount=amount,
@@ -108,51 +259,100 @@ class WalletService:
@transaction.atomic
def adjust_balance(customer_id, amount, description, user):
"""
Корректировка баланса кошелька администратором.
Может быть как положительной (пополнение), так и отрицательной (списание).
Корректировка баланса (обёртка для обратной совместимости).
Args:
customer_id: ID клиента
amount: Сумма корректировки (может быть отрицательной)
description: Обязательное описание причины корректировки
user: Пользователь, выполнивший корректировку
amount: Сумма (может быть отрицательной)
description: Описание
user: Пользователь
Returns:
WalletTransaction: Созданная транзакция
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,
return WalletService.create_adjustment(
customer=customer_id,
amount=amount,
description=description,
created_by=user
user=user
)
return txn
# ========== МЕТОДЫ ДЛЯ ВЫЗОВА ИЗ Transaction.save() ==========
@staticmethod
@transaction.atomic
def create_wallet_spend(order, amount, user):
"""
Списание из кошелька при оплате заказа.
Вызывается из Transaction.save() при payment.
Args:
order: Заказ
amount: Сумма списания
user: Пользователь
Returns:
WalletTransaction
"""
return WalletService.create_transaction(
customer=order.customer,
amount=amount,
transaction_type='spend',
order=order,
description=f'Оплата по заказу #{order.order_number}',
user=user
)
@staticmethod
@transaction.atomic
def create_wallet_deposit(order, amount, user):
"""
Пополнение кошелька при возврате.
Вызывается из Transaction.save() при refund.
Args:
order: Заказ
amount: Сумма возврата
user: Пользователь
Returns:
WalletTransaction
"""
return WalletService.create_transaction(
customer=order.customer,
amount=amount,
transaction_type='deposit',
order=order,
description=f'Возврат по заказу #{order.order_number}',
user=user
)
# ========== МЕТОДЫ ДЛЯ БУДУЩЕЙ БОНУСНОЙ СИСТЕМЫ ==========
# @staticmethod
# @transaction.atomic
# def accrue_bonus(customer, amount, reason, user=None, order=None):
# """Начислить бонусные баллы."""
# return WalletService.create_transaction(
# customer=customer,
# amount=amount,
# transaction_type='bonus_accrual',
# category='bonus',
# order=order,
# description=reason,
# user=user
# )
# @staticmethod
# @transaction.atomic
# def spend_bonus(customer, amount, order, user):
# """Списать бонусы за оплату."""
# return WalletService.create_transaction(
# customer=customer,
# amount=amount,
# transaction_type='bonus_spend',
# category='bonus',
# order=order,
# description=f'Оплата бонусами по заказу #{order.order_number}',
# user=user
# )

View File

@@ -74,6 +74,60 @@
</div>
</div>
<!-- Каналы связи -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Каналы связи</h5>
<button class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#addChannelModal">
<i class="bi bi-plus"></i> Добавить
</button>
</div>
<div class="card-body">
{% if contact_channels %}
<ul class="list-group list-group-flush">
{% for channel in contact_channels %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
{% if channel.channel_type == 'telegram' %}
<span class="badge bg-info me-2"><i class="bi bi-telegram"></i> Telegram</span>
{% elif channel.channel_type == 'instagram' %}
<span class="badge bg-danger me-2"><i class="bi bi-instagram"></i> Instagram</span>
{% elif channel.channel_type == 'whatsapp' %}
<span class="badge bg-success me-2"><i class="bi bi-whatsapp"></i> WhatsApp</span>
{% elif channel.channel_type == 'viber' %}
<span class="badge bg-purple me-2" style="background-color: #7360f2 !important;"><i class="bi bi-chat-fill"></i> Viber</span>
{% elif channel.channel_type == 'vk' %}
<span class="badge bg-primary me-2">VK</span>
{% elif channel.channel_type == 'facebook' %}
<span class="badge bg-primary me-2"><i class="bi bi-facebook"></i> Facebook</span>
{% elif channel.channel_type == 'phone' %}
<span class="badge bg-secondary me-2"><i class="bi bi-telephone"></i> Телефон</span>
{% elif channel.channel_type == 'email' %}
<span class="badge bg-secondary me-2"><i class="bi bi-envelope"></i> Email</span>
{% else %}
<span class="badge bg-dark me-2">{{ channel.get_channel_type_display }}</span>
{% endif %}
<strong>{{ channel.value }}</strong>
{% if channel.is_primary %}<span class="badge bg-warning text-dark ms-1">основной</span>{% endif %}
{% if channel.notes %}<small class="text-muted d-block mt-1">{{ channel.notes }}</small>{% endif %}
</div>
<form method="post" action="{% url 'customers:delete-contact-channel' channel.pk %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('Удалить канал?')">
<i class="bi bi-trash"></i>
</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted mb-0">Нет дополнительных каналов связи. Добавьте Instagram, Telegram и другие контакты.</p>
{% endif %}
</div>
</div>
</div>
<!-- Алерт о необходимости возврата -->
{% if refund_amount > 0 %}
<div class="col-md-12">
@@ -436,6 +490,54 @@
</div>
</div>
<!-- Модальное окно добавления канала связи -->
<div class="modal fade" id="addChannelModal" tabindex="-1" aria-labelledby="addChannelModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="{% url 'customers:add-contact-channel' customer.pk %}">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="addChannelModalLabel">Добавить канал связи</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="channel_type" class="form-label">Тип канала</label>
<select name="channel_type" id="channel_type" class="form-select" required>
<option value="telegram">Telegram</option>
<option value="instagram">Instagram</option>
<option value="whatsapp">WhatsApp</option>
<option value="viber">Viber</option>
<option value="vk">ВКонтакте</option>
<option value="facebook">Facebook</option>
<option value="phone">Телефон</option>
<option value="email">Email</option>
<option value="other">Другое</option>
</select>
</div>
<div class="mb-3">
<label for="channel_value" class="form-label">Значение</label>
<input type="text" name="value" id="channel_value" class="form-control" placeholder="@username, номер, ссылка..." required>
<small class="text-muted">Например: @flower_lover, +375291234567, flower.shop</small>
</div>
<div class="mb-3">
<label for="channel_notes" class="form-label">Примечание <span class="text-muted">(необязательно)</span></label>
<input type="text" name="notes" id="channel_notes" class="form-control" placeholder="Личный аккаунт, рабочий...">
</div>
<div class="form-check">
<input type="checkbox" name="is_primary" class="form-check-input" id="isPrimary" value="true">
<label class="form-check-label" for="isPrimary">Основной канал связи</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-success"><i class="bi bi-plus"></i> Добавить</button>
</div>
</form>
</div>
</div>
</div>
<script>
// Автооткрытие collapse при наличии якоря в URL
document.addEventListener('DOMContentLoaded', function() {

View File

@@ -14,6 +14,10 @@ urlpatterns = [
path('<int:pk>/wallet/deposit/', views.wallet_deposit, name='wallet-deposit'),
path('<int:pk>/wallet/withdraw/', views.wallet_withdraw, name='wallet-withdraw'),
# Contact channels
path('<int:customer_pk>/channels/add/', views.add_contact_channel, name='add-contact-channel'),
path('channels/<int:pk>/delete/', views.delete_contact_channel, name='delete-contact-channel'),
# AJAX API endpoints
path('api/search/', views.api_search_customers, name='api-search-customers'),
path('api/create/', views.api_create_customer, name='api-create-customer'),

View File

@@ -125,50 +125,29 @@ class Transaction(models.Model):
def save(self, *args, **kwargs):
"""При сохранении обновляем баланс заказа и обрабатываем кошелёк"""
is_new = self.pk is None
with transaction.atomic():
super().save(*args, **kwargs)
# Пересчитываем баланс заказа
self.order.recalculate_amount_paid()
# Обработка кошелька только для новых транзакций
if is_new and self.payment_method.code == 'account_balance':
from customers.models import Customer, WalletTransaction
# Блокируем запись клиента
customer = Customer.objects.select_for_update().get(pk=self.order.customer_id)
from customers.services.wallet_service import WalletService
if self.transaction_type == 'payment':
# Списание из кошелька
if customer.wallet_balance < self.amount:
raise ValidationError(
f'Недостаточно средств в кошельке '
f'(доступно {customer.wallet_balance} руб.)'
)
customer.wallet_balance = (customer.wallet_balance - self.amount).quantize(Decimal('0.01'))
customer.save(update_fields=['wallet_balance'])
WalletTransaction.objects.create(
customer=customer,
amount=self.amount,
transaction_type='spend',
WalletService.create_wallet_spend(
order=self.order,
description=f'Оплата из кошелька по заказу #{self.order.order_number}',
created_by=self.created_by
amount=self.amount,
user=self.created_by
)
elif self.transaction_type == 'refund':
# Возврат в кошелёк
customer.wallet_balance = (customer.wallet_balance + self.amount).quantize(Decimal('0.01'))
customer.save(update_fields=['wallet_balance'])
WalletTransaction.objects.create(
customer=customer,
amount=self.amount,
transaction_type='deposit',
WalletService.create_wallet_deposit(
order=self.order,
description=f'Возврат в кошелёк по заказу #{self.order.order_number}',
created_by=self.created_by
amount=self.amount,
user=self.created_by
)