Files
octopus/myproject/customers/forms.py
Andrey Smakotin b1855cc9f0 Рефакторинг системы кошелька клиентов
Основные изменения:
- Переход от денормализованного поля 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
2025-12-28 00:02:09 +03:00

93 lines
3.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, ContactChannel
class CustomerForm(forms.ModelForm):
phone = PhoneNumberField(
region='BY',
required=False,
help_text='Формат: +375XXXXXXXXX или 80XXXXXXXXX',
widget=forms.TextInput(attrs={'placeholder': '+375XXXXXXXXX'})
)
class Meta:
model = Customer
fields = ['name', 'email', 'phone', 'notes']
exclude = ['is_system_customer']
widgets = {
'notes': forms.Textarea(attrs={'rows': 3}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Ensure phone displays in E.164 format
if self.instance and self.instance.phone:
self.initial['phone'] = str(self.instance.phone)
for field_name, field in self.fields.items():
if field_name == 'notes':
# Textarea already has rows=3 from widget, just add class
field.widget.attrs.update({'class': 'form-control'})
else:
# Regular input fields get form-control class
field.widget.attrs.update({'class': 'form-control'})
def clean_email(self):
"""Нормализует пустые значения email в None"""
email = self.cleaned_data.get('email')
if not email:
return None
return email
def clean_phone(self):
"""Нормализует пустые значения телефона в None"""
phone = self.cleaned_data.get('phone')
if not phone:
return None
return phone
def clean(self):
"""Дополнительная валидация формы"""
cleaned_data = super().clean()
# Защита от редактирования системного клиента
if self.instance and self.instance.pk and self.instance.is_system_customer:
raise ValidationError(
'Системный клиент не может быть изменен. '
'Он необходим для корректной работы системы и создается автоматически.'
)
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