Files
octopus/myproject/customers/models.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

519 lines
21 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 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
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, verbose_name="Email")
# Phone with validation using django-phonenumber-field
phone = PhoneNumberField(
blank=True,
null=True,
verbose_name="Телефон",
help_text="Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат"
)
# Temporary field to store raw phone number during initialization
_raw_phone = None
# System customer flag
is_system_customer = models.BooleanField(
default=False,
db_index=True,
verbose_name="Системный клиент",
help_text="Автоматически созданный клиент для анонимных покупок и наличных продаж"
)
# Additional notes
notes = models.TextField(
blank=True,
null=True,
verbose_name="Заметки",
help_text="Заметки о клиенте, особые предпочтения и т.д."
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
class Meta:
verbose_name = "Клиент"
verbose_name_plural = "Клиенты"
indexes = [
models.Index(fields=['name']),
models.Index(fields=['email']),
models.Index(fields=['phone']),
models.Index(fields=['created_at']),
]
ordering = ['-created_at']
def __str__(self):
if self.name:
return self.name
if self.phone:
return str(self.phone)
if self.email:
return self.email
return "Безымянный клиент"
@property
def full_name(self):
"""Полное имя клиента"""
return self.name
def clean_phone(self):
"""Пользовательская очистка поля телефона для нормализации перед валидацией."""
if self.phone:
try:
# Парсим номер телефона, чтобы проверить его валидность и нормализовать
raw_phone = str(self.phone)
# Если начинается с '8' и имеет 11 цифр, это может быть российский формат
if raw_phone.startswith('8') and len(raw_phone) == 11:
# Сначала пробуем BY для белорусских номеров
parsed = phonenumbers.parse(raw_phone, "BY")
if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
# Если BY не работает, пробуем RU как запасной вариант
parsed = phonenumbers.parse(raw_phone, "RU")
if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
# Пробуем парсить без кода страны (может быть уже в международном формате)
parsed = phonenumbers.parse(raw_phone, None)
if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
except phonenumbers.NumberParseException:
# Если парсинг не удался, возвращаем как есть и позволяем валидации поля обработать это
pass
return self.phone
def clean_fields(self, exclude=None):
# Нормализуем телефон перед запуском проверки полей
if not exclude:
exclude = []
if 'phone' not in exclude and self.phone:
normalized = None
try:
normalized = self.clean_phone()
except Exception:
normalized = None
if normalized:
# присваиваем нормализованное значение (E.164), чтобы PhoneNumberField видел корректный формат
self.phone = normalized
super().clean_fields(exclude=exclude)
def clean(self):
"""Дополнительная валидация при необходимости."""
super().clean()
def save(self, *args, **kwargs):
# Защита системного клиента от изменений
if self.pk and self.is_system_customer:
# Получаем оригинальный объект из БД
try:
original = Customer.objects.get(pk=self.pk)
# Проверяем, не пытаются ли изменить критичные поля
if original.email != self.email:
raise ValidationError("Нельзя изменить email системного клиента")
if original.is_system_customer != self.is_system_customer:
raise ValidationError("Нельзя изменить флаг системного клиента")
except Customer.DoesNotExist:
pass
# Обеспечиваем нормализацию телефона, даже если save вызывается напрямую (не через форму)
# На данный момент, если вызов прошел через валидацию формы, телефон уже должен быть нормализован
# Но если save вызывается непосредственно в модели, нам все равно нужно нормализовать
if self.phone and str(self.phone).startswith('8') and len(str(self.phone)) == 11:
# Это, вероятно, номер в формате страны, который требует нормализации
try:
# Сначала пробуем BY для белорусских номеров
parsed = phonenumbers.parse(str(self.phone), "BY")
if phonenumbers.is_valid_number(parsed):
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
else:
# Если BY не работает, пробуем RU как запасной вариант
parsed = phonenumbers.parse(str(self.phone), "RU")
if phonenumbers.is_valid_number(parsed):
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
except phonenumbers.NumberParseException:
# Если парсинг не удался, не меняем и позволяем валидации поля обработать это
pass
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""Защита системного клиента от удаления"""
if self.is_system_customer:
raise ValidationError("Нельзя удалить системного клиента. Он необходим для работы системы.")
super().delete(*args, **kwargs)
@classmethod
def get_or_create_system_customer(cls):
"""
Получить или создать системного клиента для анонимных покупок.
Системный клиент используется для:
- Анонимных покупок в POS системе
- Покупок от неизвестных клиентов (проходимость)
- Наличных продаж без указания покупателя
Возвращает:
tuple: (customer, created) - объект клиента и флаг создания
"""
customer, created = cls.objects.get_or_create(
email="system@pos.customer",
defaults={
"name": "АНОНИМНЫЙ ПОКУПАТЕЛЬ (POS)",
"is_system_customer": True,
"notes": "SYSTEM_CUSTOMER - автоматически созданный клиент для анонимных покупок и наличных продаж",
}
)
return customer, created
# Методы-обёртки для работы с кошельком (вся логика в WalletService)
def pay_from_wallet(self, order, amount, user):
"""
Оплатить заказ из кошелька клиента.
Обёртка над WalletService.pay_with_wallet.
Args:
order: Заказ для оплаты
amount: Сумма к списанию
user: Пользователь, инициирующий операцию
Returns:
Decimal: Фактически списанная сумма или None
"""
from customers.services.wallet_service import WalletService
return WalletService.pay_with_wallet(order, amount, user)
def adjust_wallet(self, amount, description, user):
"""
Корректировка баланса кошелька (для админа).
Обёртка над WalletService.adjust_balance.
Args:
amount: Сумма корректировки (может быть отрицательной)
description: Обязательное описание причины
user: Пользователь, выполняющий корректировку
Returns:
WalletTransaction: Созданная транзакция
"""
from customers.services.wallet_service import WalletService
return WalletService.adjust_balance(self.pk, amount, description, user)
@property
def wallet_transactions_history(self):
"""
История транзакций кошелька клиента.
Returns:
QuerySet: WalletTransaction для этого клиента
"""
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):
"""
Получить сумму успешных заказов за указанный период.
Args:
start_date: Дата начала периода (DateField или None)
end_date: Дата окончания периода (DateField или None)
Returns:
Decimal: Сумма успешных заказов
"""
from django.db.models import Sum, Value, DecimalField
from django.db.models.functions import Coalesce
from decimal import Decimal
# Базовый queryset: только успешные заказы
queryset = self.orders.filter(status__is_positive_end=True)
# Фильтрация по датам (используем delivery__delivery_date после рефакторинга)
if start_date:
queryset = queryset.filter(delivery__delivery_date__gte=start_date)
if end_date:
queryset = queryset.filter(delivery__delivery_date__lte=end_date)
# Агрегация суммы
result = queryset.aggregate(
total=Coalesce(
Sum('total_amount'),
Value(0),
output_field=DecimalField()
)
)
return result['total'] or Decimal('0')
def get_last_year_orders_total(self):
"""
Получить сумму успешных заказов за последний календарный год.
(С этой даты прошлого года по текущую дату)
Returns:
Decimal: Сумма успешных заказов за год
"""
from datetime import date, timedelta
today = date.today()
year_ago = today - timedelta(days=365)
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(
'Customer',
on_delete=models.PROTECT,
related_name='wallet_transactions',
verbose_name="Клиент"
)
# Знаковая сумма: положительная = приход, отрицательная = расход
signed_amount = models.DecimalField(
max_digits=10,
decimal_places=2,
verbose_name="Сумма",
help_text="Положительная для пополнений, отрицательная для списаний"
)
transaction_type = models.CharField(
max_length=20,
choices=TRANSACTION_TYPE_CHOICES,
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="Заказ, к которому относится транзакция (если применимо)"
)
description = models.TextField(
blank=True,
verbose_name="Описание"
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата создания"
)
created_by = models.ForeignKey(
'accounts.CustomUser',
null=True,
blank=True,
on_delete=models.SET_NULL,
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 = "Транзакции кошелька"
ordering = ['-created_at']
indexes = [
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):
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)