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

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