Рефакторинг системы кошелька клиентов
Основные изменения: - Переход от денормализованного поля 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,7 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from .models import Customer, WalletTransaction
|
from .models import Customer, WalletTransaction, ContactChannel
|
||||||
|
|
||||||
|
|
||||||
class IsSystemCustomerFilter(admin.SimpleListFilter):
|
class IsSystemCustomerFilter(admin.SimpleListFilter):
|
||||||
@@ -44,14 +44,14 @@ class CustomerAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
date_hierarchy = 'created_at'
|
date_hierarchy = 'created_at'
|
||||||
ordering = ('-created_at',)
|
ordering = ('-created_at',)
|
||||||
readonly_fields = ('created_at', 'updated_at', 'is_system_customer', 'wallet_balance')
|
readonly_fields = ('created_at', 'updated_at', 'is_system_customer', 'wallet_balance_display')
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Основная информация', {
|
('Основная информация', {
|
||||||
'fields': ('name', 'email', 'phone', 'is_system_customer')
|
'fields': ('name', 'email', 'phone', 'is_system_customer')
|
||||||
}),
|
}),
|
||||||
('Кошелёк', {
|
('Кошелёк', {
|
||||||
'fields': ('wallet_balance',),
|
'fields': ('wallet_balance_display',),
|
||||||
}),
|
}),
|
||||||
('Заметки', {
|
('Заметки', {
|
||||||
'fields': ('notes',)
|
'fields': ('notes',)
|
||||||
@@ -64,20 +64,20 @@ class CustomerAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
def wallet_balance_display(self, obj):
|
def wallet_balance_display(self, obj):
|
||||||
"""Отображение баланса кошелька с цветом"""
|
"""Отображение баланса кошелька с цветом"""
|
||||||
if obj.wallet_balance > 0:
|
balance = obj.wallet_balance
|
||||||
|
if balance > 0:
|
||||||
return format_html(
|
return format_html(
|
||||||
'<span style="color: green; font-weight: bold;">{} руб.</span>',
|
'<span style="color: green; font-weight: bold;">{} руб.</span>',
|
||||||
obj.wallet_balance
|
balance
|
||||||
)
|
)
|
||||||
return f'{obj.wallet_balance} руб.'
|
return f'{balance} руб.'
|
||||||
wallet_balance_display.short_description = 'Баланс кошелька'
|
wallet_balance_display.short_description = 'Баланс кошелька'
|
||||||
wallet_balance_display.admin_order_field = 'wallet_balance'
|
|
||||||
|
|
||||||
def get_readonly_fields(self, request, obj=None):
|
def get_readonly_fields(self, request, obj=None):
|
||||||
"""Делаем все поля read-only для системного клиента"""
|
"""Делаем все поля read-only для системного клиента"""
|
||||||
if obj and obj.is_system_customer:
|
if obj and obj.is_system_customer:
|
||||||
# Для системного клиента все поля только для чтения
|
# Для системного клиента все поля только для чтения
|
||||||
return ['name', 'email', 'phone', 'is_system_customer', 'wallet_balance', 'notes', 'created_at', 'updated_at']
|
return ['name', 'email', 'phone', 'is_system_customer', 'wallet_balance_display', 'notes', 'created_at', 'updated_at']
|
||||||
return self.readonly_fields
|
return self.readonly_fields
|
||||||
|
|
||||||
def has_delete_permission(self, request, obj=None):
|
def has_delete_permission(self, request, obj=None):
|
||||||
@@ -98,14 +98,20 @@ class CustomerAdmin(admin.ModelAdmin):
|
|||||||
return super().changeform_view(request, object_id, form_url, extra_context)
|
return super().changeform_view(request, object_id, form_url, extra_context)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactChannelInline(admin.TabularInline):
|
||||||
|
"""Inline для управления каналами связи клиента"""
|
||||||
|
model = ContactChannel
|
||||||
|
extra = 1
|
||||||
|
fields = ('channel_type', 'value', 'is_primary', 'notes')
|
||||||
|
|
||||||
|
|
||||||
class WalletTransactionInline(admin.TabularInline):
|
class WalletTransactionInline(admin.TabularInline):
|
||||||
"""
|
"""Inline для отображения транзакций кошелька"""
|
||||||
line для отображения транзакций кошелька"""
|
|
||||||
model = WalletTransaction
|
model = WalletTransaction
|
||||||
extra = 0
|
extra = 0
|
||||||
can_delete = False
|
can_delete = False
|
||||||
readonly_fields = ('transaction_type', 'amount', 'order', 'description', 'created_at', 'created_by')
|
readonly_fields = ('created_at', 'transaction_type', 'signed_amount', 'balance_after', 'order', 'description', 'created_by')
|
||||||
fields = ('created_at', 'transaction_type', 'amount', 'order', 'description', 'created_by')
|
fields = ('created_at', 'transaction_type', 'signed_amount', 'balance_after', 'order', 'description', 'created_by')
|
||||||
ordering = ('-created_at',)
|
ordering = ('-created_at',)
|
||||||
|
|
||||||
def has_add_permission(self, request, obj=None):
|
def has_add_permission(self, request, obj=None):
|
||||||
@@ -114,32 +120,33 @@ line для отображения транзакций кошелька"""
|
|||||||
|
|
||||||
|
|
||||||
# Добавляем inline в CustomerAdmin
|
# Добавляем inline в CustomerAdmin
|
||||||
CustomerAdmin.inlines = [WalletTransactionInline]
|
CustomerAdmin.inlines = [ContactChannelInline, WalletTransactionInline]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(WalletTransaction)
|
@admin.register(WalletTransaction)
|
||||||
class WalletTransactionAdmin(admin.ModelAdmin):
|
class WalletTransactionAdmin(admin.ModelAdmin):
|
||||||
"""Админка для просмотра всех транзакций кошелька"""
|
"""Админка для просмотра всех транзакций кошелька"""
|
||||||
list_display = ('created_at', 'customer', 'transaction_type', 'amount_display', 'order', 'created_by')
|
list_display = ('created_at', 'customer', 'transaction_type', 'amount_display', 'balance_after', 'order', 'created_by')
|
||||||
list_filter = ('transaction_type', 'created_at')
|
list_filter = ('transaction_type', 'balance_category', 'created_at')
|
||||||
search_fields = ('customer__name', 'customer__email', 'customer__phone', 'description')
|
search_fields = ('customer__name', 'customer__email', 'customer__phone', 'description')
|
||||||
readonly_fields = ('customer', 'transaction_type', 'amount', 'order', 'description', 'created_at', 'created_by')
|
readonly_fields = ('customer', 'transaction_type', 'signed_amount', 'balance_category', 'balance_after', 'order', 'description', 'created_at', 'created_by')
|
||||||
date_hierarchy = 'created_at'
|
date_hierarchy = 'created_at'
|
||||||
ordering = ('-created_at',)
|
ordering = ('-created_at',)
|
||||||
|
|
||||||
def amount_display(self, obj):
|
def amount_display(self, obj):
|
||||||
"""Отображение суммы с цветом"""
|
"""Отображение суммы с цветом"""
|
||||||
if obj.transaction_type == 'deposit':
|
amount = obj.signed_amount
|
||||||
|
if amount > 0:
|
||||||
return format_html(
|
return format_html(
|
||||||
'<span style="color: green; font-weight: bold;">+{} руб.</span>',
|
'<span style="color: green; font-weight: bold;">+{} руб.</span>',
|
||||||
obj.amount
|
amount
|
||||||
)
|
)
|
||||||
elif obj.transaction_type == 'spend':
|
elif amount < 0:
|
||||||
return format_html(
|
return format_html(
|
||||||
'<span style="color: red; font-weight: bold;">-{} руб.</span>',
|
'<span style="color: red; font-weight: bold;">{} руб.</span>',
|
||||||
obj.amount
|
amount
|
||||||
)
|
)
|
||||||
return f'{obj.amount} руб.'
|
return f'{amount} руб.'
|
||||||
amount_display.short_description = 'Сумма'
|
amount_display.short_description = 'Сумма'
|
||||||
|
|
||||||
def has_add_permission(self, request):
|
def has_add_permission(self, request):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from django import forms
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from phonenumber_field.formfields import PhoneNumberField
|
from phonenumber_field.formfields import PhoneNumberField
|
||||||
from phonenumber_field.widgets import PhoneNumberPrefixWidget
|
from phonenumber_field.widgets import PhoneNumberPrefixWidget
|
||||||
from .models import Customer
|
from .models import Customer, ContactChannel
|
||||||
|
|
||||||
class CustomerForm(forms.ModelForm):
|
class CustomerForm(forms.ModelForm):
|
||||||
phone = PhoneNumberField(
|
phone = PhoneNumberField(
|
||||||
@@ -35,43 +35,17 @@ class CustomerForm(forms.ModelForm):
|
|||||||
field.widget.attrs.update({'class': 'form-control'})
|
field.widget.attrs.update({'class': 'form-control'})
|
||||||
|
|
||||||
def clean_email(self):
|
def clean_email(self):
|
||||||
"""Проверяет уникальность email при создании/редактировании"""
|
"""Нормализует пустые значения email в None"""
|
||||||
email = self.cleaned_data.get('email')
|
email = self.cleaned_data.get('email')
|
||||||
|
|
||||||
# Нормализуем пустые значения в None (Django best practice для nullable полей)
|
|
||||||
if not email:
|
if not email:
|
||||||
return None
|
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
|
return email
|
||||||
|
|
||||||
def clean_phone(self):
|
def clean_phone(self):
|
||||||
"""Проверяет уникальность телефона при создании/редактировании"""
|
"""Нормализует пустые значения телефона в None"""
|
||||||
phone = self.cleaned_data.get('phone')
|
phone = self.cleaned_data.get('phone')
|
||||||
|
|
||||||
# Нормализуем пустые значения в None (Django best practice для nullable полей)
|
|
||||||
if not phone:
|
if not phone:
|
||||||
return None
|
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
|
return phone
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
@@ -86,3 +60,34 @@ 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
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 5.0.10 on 2025-12-27 19:32
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import phonenumber_field.modelfields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('customers', '0002_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='customer',
|
||||||
|
name='email',
|
||||||
|
field=models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='customer',
|
||||||
|
name='phone',
|
||||||
|
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат', max_length=128, null=True, region=None, verbose_name='Телефон'),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ContactChannel',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('channel_type', models.CharField(choices=[('phone', 'Телефон'), ('email', 'Email'), ('telegram', 'Telegram'), ('instagram', 'Instagram'), ('whatsapp', 'WhatsApp'), ('viber', 'Viber'), ('vk', 'ВКонтакте'), ('facebook', 'Facebook'), ('other', 'Другое')], max_length=20, verbose_name='Тип канала')),
|
||||||
|
('value', models.CharField(help_text='Username, номер телефона, email и т.д.', max_length=255, verbose_name='Значение')),
|
||||||
|
('is_primary', models.BooleanField(default=False, verbose_name='Основной')),
|
||||||
|
('notes', models.CharField(blank=True, max_length=255, verbose_name='Примечание')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||||
|
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contact_channels', to='customers.customer', verbose_name='Клиент')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Канал связи',
|
||||||
|
'verbose_name_plural': 'Каналы связи',
|
||||||
|
'ordering': ['-is_primary', 'channel_type'],
|
||||||
|
'indexes': [models.Index(fields=['channel_type', 'value'], name='customers_c_channel_179e89_idx'), models.Index(fields=['customer'], name='customers_c_custome_f14e0e_idx')],
|
||||||
|
'unique_together': {('channel_type', 'value')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
146
myproject/customers/migrations/0004_wallet_refactoring.py
Normal file
146
myproject/customers/migrations/0004_wallet_refactoring.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# Generated by Django 5.0.10 on 2025-12-27 20:24
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from decimal import Decimal
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def populate_signed_amount(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Заполняем signed_amount на основе старого amount и типа транзакции.
|
||||||
|
spend -> отрицательная сумма
|
||||||
|
deposit/adjustment -> положительная сумма
|
||||||
|
"""
|
||||||
|
WalletTransaction = apps.get_model('customers', 'WalletTransaction')
|
||||||
|
|
||||||
|
for txn in WalletTransaction.objects.all():
|
||||||
|
if txn.transaction_type == 'spend':
|
||||||
|
txn.signed_amount = -abs(txn.amount)
|
||||||
|
else:
|
||||||
|
# deposit, adjustment - положительные
|
||||||
|
txn.signed_amount = abs(txn.amount)
|
||||||
|
txn.save(update_fields=['signed_amount'])
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_balance_after(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Вычисляем balance_after для всех существующих транзакций.
|
||||||
|
"""
|
||||||
|
Customer = apps.get_model('customers', 'Customer')
|
||||||
|
WalletTransaction = apps.get_model('customers', 'WalletTransaction')
|
||||||
|
|
||||||
|
for customer in Customer.objects.all():
|
||||||
|
running_balance = Decimal('0')
|
||||||
|
|
||||||
|
# Обрабатываем транзакции в хронологическом порядке
|
||||||
|
for txn in WalletTransaction.objects.filter(customer=customer).order_by('created_at'):
|
||||||
|
running_balance += txn.signed_amount or Decimal('0')
|
||||||
|
txn.balance_after = running_balance
|
||||||
|
txn.save(update_fields=['balance_after'])
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_populate(apps, schema_editor):
|
||||||
|
"""Обратная операция - ничего не делаем."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('customers', '0003_alter_customer_email_alter_customer_phone_and_more'),
|
||||||
|
('orders', '0008_historicalorder_needs_delivery_photo_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# 1. Добавляем новые поля (signed_amount временно nullable)
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='wallettransaction',
|
||||||
|
name='signed_amount',
|
||||||
|
field=models.DecimalField(
|
||||||
|
decimal_places=2,
|
||||||
|
max_digits=10,
|
||||||
|
null=True, # Временно nullable для миграции данных
|
||||||
|
help_text='Положительная для пополнений, отрицательная для списаний',
|
||||||
|
verbose_name='Сумма'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='wallettransaction',
|
||||||
|
name='balance_after',
|
||||||
|
field=models.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=2,
|
||||||
|
help_text='Баланс кошелька после применения этой транзакции',
|
||||||
|
max_digits=10,
|
||||||
|
null=True,
|
||||||
|
verbose_name='Баланс после'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='wallettransaction',
|
||||||
|
name='balance_category',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[('money', 'Реальные деньги')],
|
||||||
|
default='money',
|
||||||
|
max_length=20,
|
||||||
|
verbose_name='Категория'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# 2. Копируем данные из amount в signed_amount
|
||||||
|
migrations.RunPython(populate_signed_amount, reverse_populate),
|
||||||
|
|
||||||
|
# 3. Вычисляем balance_after
|
||||||
|
migrations.RunPython(calculate_balance_after, reverse_populate),
|
||||||
|
|
||||||
|
# 4. Делаем signed_amount NOT NULL
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='wallettransaction',
|
||||||
|
name='signed_amount',
|
||||||
|
field=models.DecimalField(
|
||||||
|
decimal_places=2,
|
||||||
|
max_digits=10,
|
||||||
|
help_text='Положительная для пополнений, отрицательная для списаний',
|
||||||
|
verbose_name='Сумма'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# 5. Удаляем старое поле amount
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='wallettransaction',
|
||||||
|
name='amount',
|
||||||
|
),
|
||||||
|
|
||||||
|
# 6. Удаляем wallet_balance из Customer
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='customer',
|
||||||
|
name='wallet_balance',
|
||||||
|
),
|
||||||
|
|
||||||
|
# 7. Обновляем связь с Order (добавляем related_name)
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='wallettransaction',
|
||||||
|
name='order',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text='Заказ, к которому относится транзакция (если применимо)',
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
related_name='wallet_transactions',
|
||||||
|
to='orders.order',
|
||||||
|
verbose_name='Заказ'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
# 8. Добавляем индексы
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='wallettransaction',
|
||||||
|
index=models.Index(fields=['balance_category'], name='customers_w_balance_81f0a9_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='wallettransaction',
|
||||||
|
index=models.Index(fields=['customer', 'balance_category'], name='customers_w_custome_060570_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
import phonenumbers
|
import phonenumbers
|
||||||
|
from django.core.cache import cache
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
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
|
from phonenumber_field.modelfields import PhoneNumberField
|
||||||
|
|
||||||
|
|
||||||
@@ -11,13 +16,12 @@ class Customer(models.Model):
|
|||||||
# Name field that is not required to be unique
|
# Name field that is not required to be unique
|
||||||
name = models.CharField(max_length=200, blank=True, verbose_name="Имя")
|
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 with validation using django-phonenumber-field
|
||||||
phone = PhoneNumberField(
|
phone = PhoneNumberField(
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
unique=True,
|
|
||||||
verbose_name="Телефон",
|
verbose_name="Телефон",
|
||||||
help_text="Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат"
|
help_text="Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат"
|
||||||
)
|
)
|
||||||
@@ -25,15 +29,6 @@ class Customer(models.Model):
|
|||||||
# Temporary field to store raw phone number during initialization
|
# Temporary field to store raw phone number during initialization
|
||||||
_raw_phone = None
|
_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
|
# System customer flag
|
||||||
is_system_customer = models.BooleanField(
|
is_system_customer = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
@@ -81,20 +76,6 @@ class Customer(models.Model):
|
|||||||
"""Полное имя клиента"""
|
"""Полное имя клиента"""
|
||||||
return self.name
|
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):
|
def clean_phone(self):
|
||||||
"""Пользовательская очистка поля телефона для нормализации перед валидацией."""
|
"""Пользовательская очистка поля телефона для нормализации перед валидацией."""
|
||||||
if self.phone:
|
if self.phone:
|
||||||
@@ -252,6 +233,68 @@ class Customer(models.Model):
|
|||||||
"""
|
"""
|
||||||
return self.wallet_transactions.all()
|
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):
|
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)
|
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):
|
class WalletTransaction(models.Model):
|
||||||
"""
|
"""
|
||||||
Транзакция по кошельку клиента.
|
Транзакция по кошельку клиента.
|
||||||
Хранит историю всех пополнений, списаний и корректировок баланса.
|
Хранит историю всех пополнений, списаний и корректировок баланса.
|
||||||
|
|
||||||
|
Архитектура: баланс кошелька = SUM(signed_amount) всех транзакций клиента.
|
||||||
|
Это единственный источник правды о балансе.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Типы транзакций (расширяемо для будущей бонусной системы)
|
||||||
TRANSACTION_TYPE_CHOICES = [
|
TRANSACTION_TYPE_CHOICES = [
|
||||||
('deposit', 'Пополнение'),
|
('deposit', 'Пополнение'),
|
||||||
('spend', 'Списание'),
|
('spend', 'Списание'),
|
||||||
('adjustment', 'Корректировка'),
|
('adjustment', 'Корректировка'),
|
||||||
|
# Для будущей бонусной системы:
|
||||||
|
# ('bonus_accrual', 'Начисление бонусов'),
|
||||||
|
# ('bonus_spend', 'Списание бонусов'),
|
||||||
|
# ('cashback', 'Кэшбэк'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Категории баланса (для разделения "реальные деньги" vs "бонусы")
|
||||||
|
BALANCE_CATEGORY_CHOICES = [
|
||||||
|
('money', 'Реальные деньги'),
|
||||||
|
# ('bonus', 'Бонусные баллы'), # Для будущей реализации
|
||||||
]
|
]
|
||||||
|
|
||||||
customer = models.ForeignKey(
|
customer = models.ForeignKey(
|
||||||
@@ -321,10 +435,12 @@ class WalletTransaction(models.Model):
|
|||||||
verbose_name="Клиент"
|
verbose_name="Клиент"
|
||||||
)
|
)
|
||||||
|
|
||||||
amount = models.DecimalField(
|
# Знаковая сумма: положительная = приход, отрицательная = расход
|
||||||
|
signed_amount = models.DecimalField(
|
||||||
max_digits=10,
|
max_digits=10,
|
||||||
decimal_places=2,
|
decimal_places=2,
|
||||||
verbose_name="Сумма"
|
verbose_name="Сумма",
|
||||||
|
help_text="Положительная для пополнений, отрицательная для списаний"
|
||||||
)
|
)
|
||||||
|
|
||||||
transaction_type = models.CharField(
|
transaction_type = models.CharField(
|
||||||
@@ -333,11 +449,20 @@ class WalletTransaction(models.Model):
|
|||||||
verbose_name="Тип транзакции"
|
verbose_name="Тип транзакции"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Категория баланса (подготовка к бонусной системе)
|
||||||
|
balance_category = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=BALANCE_CATEGORY_CHOICES,
|
||||||
|
default='money',
|
||||||
|
verbose_name="Категория"
|
||||||
|
)
|
||||||
|
|
||||||
order = models.ForeignKey(
|
order = models.ForeignKey(
|
||||||
'orders.Order',
|
'orders.Order',
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
on_delete=models.PROTECT,
|
on_delete=models.PROTECT,
|
||||||
|
related_name='wallet_transactions',
|
||||||
verbose_name="Заказ",
|
verbose_name="Заказ",
|
||||||
help_text="Заказ, к которому относится транзакция (если применимо)"
|
help_text="Заказ, к которому относится транзакция (если применимо)"
|
||||||
)
|
)
|
||||||
@@ -360,6 +485,16 @@ class WalletTransaction(models.Model):
|
|||||||
verbose_name="Создано пользователем"
|
verbose_name="Создано пользователем"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Баланс после транзакции (для быстрого аудита и отображения в истории)
|
||||||
|
balance_after = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Баланс после",
|
||||||
|
help_text="Баланс кошелька после применения этой транзакции"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Транзакция кошелька"
|
verbose_name = "Транзакция кошелька"
|
||||||
verbose_name_plural = "Транзакции кошелька"
|
verbose_name_plural = "Транзакции кошелька"
|
||||||
@@ -368,8 +503,16 @@ class WalletTransaction(models.Model):
|
|||||||
models.Index(fields=['customer', '-created_at']),
|
models.Index(fields=['customer', '-created_at']),
|
||||||
models.Index(fields=['transaction_type']),
|
models.Index(fields=['transaction_type']),
|
||||||
models.Index(fields=['order']),
|
models.Index(fields=['order']),
|
||||||
|
models.Index(fields=['balance_category']),
|
||||||
|
models.Index(fields=['customer', 'balance_category']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Сервис для работы с кошельком клиента.
|
Сервис для работы с кошельком клиента.
|
||||||
Обрабатывает пополнения, списания и корректировки баланса.
|
Все операции создают транзакции в WalletTransaction.
|
||||||
|
Баланс вычисляется как SUM(signed_amount).
|
||||||
"""
|
"""
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
|
|
||||||
@@ -20,20 +22,168 @@ def _quantize(value):
|
|||||||
class WalletService:
|
class WalletService:
|
||||||
"""
|
"""
|
||||||
Сервис для управления кошельком клиента.
|
Сервис для управления кошельком клиента.
|
||||||
Все операции атомарны и блокируют запись клиента для избежания race conditions.
|
|
||||||
|
Архитектура:
|
||||||
|
- Баланс = SUM(signed_amount) транзакций (нет денормализованного поля)
|
||||||
|
- Все операции атомарны с блокировкой строк
|
||||||
|
- Кеширование баланса для производительности
|
||||||
|
- Инвалидация кеша при каждой транзакции
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def create_transaction(
|
||||||
|
customer,
|
||||||
|
amount,
|
||||||
|
transaction_type,
|
||||||
|
category='money',
|
||||||
|
order=None,
|
||||||
|
description='',
|
||||||
|
user=None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Создать транзакцию кошелька (базовый метод).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer: Customer или customer_id
|
||||||
|
amount: Decimal - положительная сумма
|
||||||
|
transaction_type: str - 'deposit', 'spend', 'adjustment'
|
||||||
|
category: str - 'money' или 'bonus'
|
||||||
|
order: Order - связанный заказ (опционально)
|
||||||
|
description: str - описание
|
||||||
|
user: CustomUser - кто создал
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WalletTransaction
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: если некорректные данные или недостаточно средств
|
||||||
|
"""
|
||||||
|
from customers.models import Customer, WalletTransaction
|
||||||
|
|
||||||
|
# Получаем и блокируем клиента
|
||||||
|
if isinstance(customer, int):
|
||||||
|
customer = Customer.objects.select_for_update().get(pk=customer)
|
||||||
|
else:
|
||||||
|
customer = Customer.objects.select_for_update().get(pk=customer.pk)
|
||||||
|
|
||||||
|
amount = _quantize(amount)
|
||||||
|
if amount <= 0:
|
||||||
|
raise ValueError('Сумма должна быть положительной')
|
||||||
|
|
||||||
|
# Определяем знак суммы
|
||||||
|
if transaction_type == 'spend':
|
||||||
|
signed_amount = -amount
|
||||||
|
else:
|
||||||
|
signed_amount = amount
|
||||||
|
|
||||||
|
# Получаем текущий баланс (без кеша для точности)
|
||||||
|
current_balance = customer.get_wallet_balance(category=category, use_cache=False)
|
||||||
|
|
||||||
|
# Проверяем баланс для списания
|
||||||
|
if signed_amount < 0:
|
||||||
|
if current_balance + signed_amount < 0:
|
||||||
|
raise ValueError(
|
||||||
|
f'Недостаточно средств. Баланс: {current_balance}, '
|
||||||
|
f'запрошено: {abs(signed_amount)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Вычисляем баланс после транзакции
|
||||||
|
balance_after = current_balance + signed_amount
|
||||||
|
|
||||||
|
# Создаём транзакцию
|
||||||
|
txn = WalletTransaction.objects.create(
|
||||||
|
customer=customer,
|
||||||
|
signed_amount=signed_amount,
|
||||||
|
transaction_type=transaction_type,
|
||||||
|
balance_category=category,
|
||||||
|
order=order,
|
||||||
|
description=description,
|
||||||
|
created_by=user,
|
||||||
|
balance_after=balance_after
|
||||||
|
)
|
||||||
|
|
||||||
|
# Инвалидируем кеш
|
||||||
|
customer.invalidate_wallet_cache(category=category)
|
||||||
|
|
||||||
|
return txn
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def create_adjustment(customer, amount, description, user, category='money'):
|
||||||
|
"""
|
||||||
|
Корректировка баланса (может быть положительной или отрицательной).
|
||||||
|
|
||||||
|
Используется для административных операций:
|
||||||
|
- Пополнение кошелька
|
||||||
|
- Списание средств
|
||||||
|
- Исправление ошибок
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer: Customer или customer_id
|
||||||
|
amount: Decimal - сумма (может быть отрицательной)
|
||||||
|
description: str - обязательное описание
|
||||||
|
user: CustomUser
|
||||||
|
category: str - 'money' или 'bonus'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WalletTransaction
|
||||||
|
"""
|
||||||
|
from customers.models import Customer, WalletTransaction
|
||||||
|
|
||||||
|
if not description or not description.strip():
|
||||||
|
raise ValueError('Описание обязательно для корректировки баланса')
|
||||||
|
|
||||||
|
amount = _quantize(amount)
|
||||||
|
if amount == 0:
|
||||||
|
raise ValueError('Сумма корректировки не может быть нулевой')
|
||||||
|
|
||||||
|
# Получаем и блокируем клиента
|
||||||
|
if isinstance(customer, int):
|
||||||
|
customer = Customer.objects.select_for_update().get(pk=customer)
|
||||||
|
else:
|
||||||
|
customer = Customer.objects.select_for_update().get(pk=customer.pk)
|
||||||
|
|
||||||
|
# Получаем текущий баланс
|
||||||
|
current_balance = customer.get_wallet_balance(category=category, use_cache=False)
|
||||||
|
|
||||||
|
# Проверяем, что баланс не уйдёт в минус
|
||||||
|
if current_balance + amount < 0:
|
||||||
|
raise ValueError(
|
||||||
|
f'Корректировка приведёт к отрицательному балансу. '
|
||||||
|
f'Текущий баланс: {current_balance}, корректировка: {amount}'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Вычисляем баланс после
|
||||||
|
balance_after = current_balance + amount
|
||||||
|
|
||||||
|
# Создаём транзакцию
|
||||||
|
txn = WalletTransaction.objects.create(
|
||||||
|
customer=customer,
|
||||||
|
signed_amount=amount, # Может быть положительной или отрицательной
|
||||||
|
transaction_type='adjustment',
|
||||||
|
balance_category=category,
|
||||||
|
order=None,
|
||||||
|
description=description,
|
||||||
|
created_by=user,
|
||||||
|
balance_after=balance_after
|
||||||
|
)
|
||||||
|
|
||||||
|
# Инвалидируем кеш
|
||||||
|
customer.invalidate_wallet_cache(category=category)
|
||||||
|
|
||||||
|
return txn
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def pay_with_wallet(order, amount, user):
|
def pay_with_wallet(order, amount, user):
|
||||||
"""
|
"""
|
||||||
Оплата заказа из кошелька клиента.
|
Оплата заказа из кошелька клиента.
|
||||||
Создаёт транзакцию. Списание из кошелька происходит автоматически в Transaction.save().
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
order: Заказ для оплаты
|
order: Заказ для оплаты
|
||||||
amount: Запрашиваемая сумма для списания
|
amount: Запрашиваемая сумма
|
||||||
user: Пользователь, инициировавший операцию
|
user: Пользователь
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Decimal: Фактически списанная сумма или None
|
Decimal: Фактически списанная сумма или None
|
||||||
@@ -41,26 +191,28 @@ class WalletService:
|
|||||||
from customers.models import Customer
|
from customers.models import Customer
|
||||||
from orders.services.transaction_service import TransactionService
|
from orders.services.transaction_service import TransactionService
|
||||||
|
|
||||||
# Округляем запрошенную сумму
|
|
||||||
amount = _quantize(amount)
|
amount = _quantize(amount)
|
||||||
if amount <= 0:
|
if amount <= 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Блокируем запись клиента для проверки баланса
|
# Блокируем клиента для проверки баланса
|
||||||
customer = Customer.objects.select_for_update().get(pk=order.customer_id)
|
customer = Customer.objects.select_for_update().get(pk=order.customer_id)
|
||||||
|
|
||||||
# Остаток к оплате по заказу
|
# Текущий баланс
|
||||||
|
wallet_balance = customer.get_wallet_balance(use_cache=False)
|
||||||
|
|
||||||
|
# Остаток к оплате
|
||||||
amount_due = order.total_amount - order.amount_paid
|
amount_due = order.total_amount - order.amount_paid
|
||||||
|
|
||||||
# Определяем фактическую сумму списания (минимум из трёх)
|
# Фактическая сумма (минимум из трёх)
|
||||||
usable_amount = min(amount, customer.wallet_balance, amount_due)
|
usable_amount = min(amount, wallet_balance, amount_due)
|
||||||
usable_amount = _quantize(usable_amount)
|
usable_amount = _quantize(usable_amount)
|
||||||
|
|
||||||
if usable_amount <= 0:
|
if usable_amount <= 0:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Создаём транзакцию
|
# Создаём транзакцию платежа
|
||||||
# Transaction.save() автоматически спишет из кошелька и создаст WalletTransaction
|
# Transaction.save() вызовет create_wallet_spend()
|
||||||
TransactionService.create_payment(
|
TransactionService.create_payment(
|
||||||
order=order,
|
order=order,
|
||||||
amount=usable_amount,
|
amount=usable_amount,
|
||||||
@@ -76,12 +228,11 @@ class WalletService:
|
|||||||
def refund_wallet_payment(order, amount, user):
|
def refund_wallet_payment(order, amount, user):
|
||||||
"""
|
"""
|
||||||
Возврат средств в кошелёк.
|
Возврат средств в кошелёк.
|
||||||
Используется для создания транзакции возврата с кошельком.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
order: Заказ, по которому был платёж
|
order: Заказ
|
||||||
amount: Сумма возврата
|
amount: Сумма возврата
|
||||||
user: Пользователь, инициировавший возврат
|
user: Пользователь
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Decimal: Возвращённая сумма
|
Decimal: Возвращённая сумма
|
||||||
@@ -93,7 +244,7 @@ class WalletService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Создаём транзакцию возврата
|
# Создаём транзакцию возврата
|
||||||
# Transaction.save() автоматически вернёт в кошелёк и создаст WalletTransaction
|
# Transaction.save() вызовет create_wallet_deposit()
|
||||||
TransactionService.create_refund(
|
TransactionService.create_refund(
|
||||||
order=order,
|
order=order,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
@@ -108,51 +259,100 @@ class WalletService:
|
|||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def adjust_balance(customer_id, amount, description, user):
|
def adjust_balance(customer_id, amount, description, user):
|
||||||
"""
|
"""
|
||||||
Корректировка баланса кошелька администратором.
|
Корректировка баланса (обёртка для обратной совместимости).
|
||||||
Может быть как положительной (пополнение), так и отрицательной (списание).
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
customer_id: ID клиента
|
customer_id: ID клиента
|
||||||
amount: Сумма корректировки (может быть отрицательной)
|
amount: Сумма (может быть отрицательной)
|
||||||
description: Обязательное описание причины корректировки
|
description: Описание
|
||||||
user: Пользователь, выполнивший корректировку
|
user: Пользователь
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
WalletTransaction: Созданная транзакция
|
WalletTransaction
|
||||||
"""
|
"""
|
||||||
from customers.models import Customer, WalletTransaction
|
return WalletService.create_adjustment(
|
||||||
|
customer=customer_id,
|
||||||
if not description or not description.strip():
|
amount=amount,
|
||||||
raise ValueError('Описание обязательно для корректировки баланса')
|
|
||||||
|
|
||||||
amount = _quantize(amount)
|
|
||||||
if amount == 0:
|
|
||||||
raise ValueError('Сумма корректировки не может быть нулевой')
|
|
||||||
|
|
||||||
# Блокируем запись клиента
|
|
||||||
customer = Customer.objects.select_for_update().get(pk=customer_id)
|
|
||||||
|
|
||||||
# Применяем корректировку
|
|
||||||
new_balance = _quantize(customer.wallet_balance + amount)
|
|
||||||
|
|
||||||
# Проверяем, что баланс не уйдёт в минус
|
|
||||||
if new_balance < 0:
|
|
||||||
raise ValueError(
|
|
||||||
f'Корректировка приведёт к отрицательному балансу '
|
|
||||||
f'({new_balance} руб.). Операция отклонена.'
|
|
||||||
)
|
|
||||||
|
|
||||||
customer.wallet_balance = new_balance
|
|
||||||
customer.save(update_fields=['wallet_balance'])
|
|
||||||
|
|
||||||
# Создаём транзакцию
|
|
||||||
txn = WalletTransaction.objects.create(
|
|
||||||
customer=customer,
|
|
||||||
amount=abs(amount),
|
|
||||||
transaction_type='adjustment',
|
|
||||||
order=None,
|
|
||||||
description=description,
|
description=description,
|
||||||
created_by=user
|
user=user
|
||||||
)
|
)
|
||||||
|
|
||||||
return txn
|
# ========== МЕТОДЫ ДЛЯ ВЫЗОВА ИЗ Transaction.save() ==========
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def create_wallet_spend(order, amount, user):
|
||||||
|
"""
|
||||||
|
Списание из кошелька при оплате заказа.
|
||||||
|
Вызывается из Transaction.save() при payment.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
order: Заказ
|
||||||
|
amount: Сумма списания
|
||||||
|
user: Пользователь
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WalletTransaction
|
||||||
|
"""
|
||||||
|
return WalletService.create_transaction(
|
||||||
|
customer=order.customer,
|
||||||
|
amount=amount,
|
||||||
|
transaction_type='spend',
|
||||||
|
order=order,
|
||||||
|
description=f'Оплата по заказу #{order.order_number}',
|
||||||
|
user=user
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def create_wallet_deposit(order, amount, user):
|
||||||
|
"""
|
||||||
|
Пополнение кошелька при возврате.
|
||||||
|
Вызывается из Transaction.save() при refund.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
order: Заказ
|
||||||
|
amount: Сумма возврата
|
||||||
|
user: Пользователь
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
WalletTransaction
|
||||||
|
"""
|
||||||
|
return WalletService.create_transaction(
|
||||||
|
customer=order.customer,
|
||||||
|
amount=amount,
|
||||||
|
transaction_type='deposit',
|
||||||
|
order=order,
|
||||||
|
description=f'Возврат по заказу #{order.order_number}',
|
||||||
|
user=user
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========== МЕТОДЫ ДЛЯ БУДУЩЕЙ БОНУСНОЙ СИСТЕМЫ ==========
|
||||||
|
|
||||||
|
# @staticmethod
|
||||||
|
# @transaction.atomic
|
||||||
|
# def accrue_bonus(customer, amount, reason, user=None, order=None):
|
||||||
|
# """Начислить бонусные баллы."""
|
||||||
|
# return WalletService.create_transaction(
|
||||||
|
# customer=customer,
|
||||||
|
# amount=amount,
|
||||||
|
# transaction_type='bonus_accrual',
|
||||||
|
# category='bonus',
|
||||||
|
# order=order,
|
||||||
|
# description=reason,
|
||||||
|
# user=user
|
||||||
|
# )
|
||||||
|
|
||||||
|
# @staticmethod
|
||||||
|
# @transaction.atomic
|
||||||
|
# def spend_bonus(customer, amount, order, user):
|
||||||
|
# """Списать бонусы за оплату."""
|
||||||
|
# return WalletService.create_transaction(
|
||||||
|
# customer=customer,
|
||||||
|
# amount=amount,
|
||||||
|
# transaction_type='bonus_spend',
|
||||||
|
# category='bonus',
|
||||||
|
# order=order,
|
||||||
|
# description=f'Оплата бонусами по заказу #{order.order_number}',
|
||||||
|
# user=user
|
||||||
|
# )
|
||||||
|
|||||||
@@ -74,6 +74,60 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Каналы связи -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">Каналы связи</h5>
|
||||||
|
<button class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#addChannelModal">
|
||||||
|
<i class="bi bi-plus"></i> Добавить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if contact_channels %}
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for channel in contact_channels %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
{% if channel.channel_type == 'telegram' %}
|
||||||
|
<span class="badge bg-info me-2"><i class="bi bi-telegram"></i> Telegram</span>
|
||||||
|
{% elif channel.channel_type == 'instagram' %}
|
||||||
|
<span class="badge bg-danger me-2"><i class="bi bi-instagram"></i> Instagram</span>
|
||||||
|
{% elif channel.channel_type == 'whatsapp' %}
|
||||||
|
<span class="badge bg-success me-2"><i class="bi bi-whatsapp"></i> WhatsApp</span>
|
||||||
|
{% elif channel.channel_type == 'viber' %}
|
||||||
|
<span class="badge bg-purple me-2" style="background-color: #7360f2 !important;"><i class="bi bi-chat-fill"></i> Viber</span>
|
||||||
|
{% elif channel.channel_type == 'vk' %}
|
||||||
|
<span class="badge bg-primary me-2">VK</span>
|
||||||
|
{% elif channel.channel_type == 'facebook' %}
|
||||||
|
<span class="badge bg-primary me-2"><i class="bi bi-facebook"></i> Facebook</span>
|
||||||
|
{% elif channel.channel_type == 'phone' %}
|
||||||
|
<span class="badge bg-secondary me-2"><i class="bi bi-telephone"></i> Телефон</span>
|
||||||
|
{% elif channel.channel_type == 'email' %}
|
||||||
|
<span class="badge bg-secondary me-2"><i class="bi bi-envelope"></i> Email</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-dark me-2">{{ channel.get_channel_type_display }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<strong>{{ channel.value }}</strong>
|
||||||
|
{% if channel.is_primary %}<span class="badge bg-warning text-dark ms-1">основной</span>{% endif %}
|
||||||
|
{% if channel.notes %}<small class="text-muted d-block mt-1">{{ channel.notes }}</small>{% endif %}
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{% url 'customers:delete-contact-channel' channel.pk %}" class="d-inline">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('Удалить канал?')">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">Нет дополнительных каналов связи. Добавьте Instagram, Telegram и другие контакты.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Алерт о необходимости возврата -->
|
<!-- Алерт о необходимости возврата -->
|
||||||
{% if refund_amount > 0 %}
|
{% if refund_amount > 0 %}
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
@@ -436,6 +490,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно добавления канала связи -->
|
||||||
|
<div class="modal fade" id="addChannelModal" tabindex="-1" aria-labelledby="addChannelModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="post" action="{% url 'customers:add-contact-channel' customer.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="addChannelModalLabel">Добавить канал связи</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="channel_type" class="form-label">Тип канала</label>
|
||||||
|
<select name="channel_type" id="channel_type" class="form-select" required>
|
||||||
|
<option value="telegram">Telegram</option>
|
||||||
|
<option value="instagram">Instagram</option>
|
||||||
|
<option value="whatsapp">WhatsApp</option>
|
||||||
|
<option value="viber">Viber</option>
|
||||||
|
<option value="vk">ВКонтакте</option>
|
||||||
|
<option value="facebook">Facebook</option>
|
||||||
|
<option value="phone">Телефон</option>
|
||||||
|
<option value="email">Email</option>
|
||||||
|
<option value="other">Другое</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="channel_value" class="form-label">Значение</label>
|
||||||
|
<input type="text" name="value" id="channel_value" class="form-control" placeholder="@username, номер, ссылка..." required>
|
||||||
|
<small class="text-muted">Например: @flower_lover, +375291234567, flower.shop</small>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="channel_notes" class="form-label">Примечание <span class="text-muted">(необязательно)</span></label>
|
||||||
|
<input type="text" name="notes" id="channel_notes" class="form-control" placeholder="Личный аккаунт, рабочий...">
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" name="is_primary" class="form-check-input" id="isPrimary" value="true">
|
||||||
|
<label class="form-check-label" for="isPrimary">Основной канал связи</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="submit" class="btn btn-success"><i class="bi bi-plus"></i> Добавить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Автооткрытие collapse при наличии якоря в URL
|
// Автооткрытие collapse при наличии якоря в URL
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ urlpatterns = [
|
|||||||
path('<int:pk>/wallet/deposit/', views.wallet_deposit, name='wallet-deposit'),
|
path('<int:pk>/wallet/deposit/', views.wallet_deposit, name='wallet-deposit'),
|
||||||
path('<int:pk>/wallet/withdraw/', views.wallet_withdraw, name='wallet-withdraw'),
|
path('<int:pk>/wallet/withdraw/', views.wallet_withdraw, name='wallet-withdraw'),
|
||||||
|
|
||||||
|
# Contact channels
|
||||||
|
path('<int:customer_pk>/channels/add/', views.add_contact_channel, name='add-contact-channel'),
|
||||||
|
path('channels/<int:pk>/delete/', views.delete_contact_channel, name='delete-contact-channel'),
|
||||||
|
|
||||||
# AJAX API endpoints
|
# AJAX API endpoints
|
||||||
path('api/search/', views.api_search_customers, name='api-search-customers'),
|
path('api/search/', views.api_search_customers, name='api-search-customers'),
|
||||||
path('api/create/', views.api_create_customer, name='api-create-customer'),
|
path('api/create/', views.api_create_customer, name='api-create-customer'),
|
||||||
|
|||||||
@@ -134,41 +134,20 @@ class Transaction(models.Model):
|
|||||||
|
|
||||||
# Обработка кошелька только для новых транзакций
|
# Обработка кошелька только для новых транзакций
|
||||||
if is_new and self.payment_method.code == 'account_balance':
|
if is_new and self.payment_method.code == 'account_balance':
|
||||||
from customers.models import Customer, WalletTransaction
|
from customers.services.wallet_service import WalletService
|
||||||
|
|
||||||
# Блокируем запись клиента
|
|
||||||
customer = Customer.objects.select_for_update().get(pk=self.order.customer_id)
|
|
||||||
|
|
||||||
if self.transaction_type == 'payment':
|
if self.transaction_type == 'payment':
|
||||||
# Списание из кошелька
|
# Списание из кошелька
|
||||||
if customer.wallet_balance < self.amount:
|
WalletService.create_wallet_spend(
|
||||||
raise ValidationError(
|
|
||||||
f'Недостаточно средств в кошельке '
|
|
||||||
f'(доступно {customer.wallet_balance} руб.)'
|
|
||||||
)
|
|
||||||
|
|
||||||
customer.wallet_balance = (customer.wallet_balance - self.amount).quantize(Decimal('0.01'))
|
|
||||||
customer.save(update_fields=['wallet_balance'])
|
|
||||||
|
|
||||||
WalletTransaction.objects.create(
|
|
||||||
customer=customer,
|
|
||||||
amount=self.amount,
|
|
||||||
transaction_type='spend',
|
|
||||||
order=self.order,
|
order=self.order,
|
||||||
description=f'Оплата из кошелька по заказу #{self.order.order_number}',
|
amount=self.amount,
|
||||||
created_by=self.created_by
|
user=self.created_by
|
||||||
)
|
)
|
||||||
|
|
||||||
elif self.transaction_type == 'refund':
|
elif self.transaction_type == 'refund':
|
||||||
# Возврат в кошелёк
|
# Возврат в кошелёк
|
||||||
customer.wallet_balance = (customer.wallet_balance + self.amount).quantize(Decimal('0.01'))
|
WalletService.create_wallet_deposit(
|
||||||
customer.save(update_fields=['wallet_balance'])
|
|
||||||
|
|
||||||
WalletTransaction.objects.create(
|
|
||||||
customer=customer,
|
|
||||||
amount=self.amount,
|
|
||||||
transaction_type='deposit',
|
|
||||||
order=self.order,
|
order=self.order,
|
||||||
description=f'Возврат в кошелёк по заказу #{self.order.order_number}',
|
amount=self.amount,
|
||||||
created_by=self.created_by
|
user=self.created_by
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user