Рефакторинг системы кошелька клиентов
Основные изменения: - Переход от денормализованного поля 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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user