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

Основные изменения:
- Переход от денормализованного поля 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

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