Рефакторинг системы кошелька клиентов
Основные изменения: - Переход от денормализованного поля 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.db import models
|
||||
from django.utils.html import format_html
|
||||
from .models import Customer, WalletTransaction
|
||||
from .models import Customer, WalletTransaction, ContactChannel
|
||||
|
||||
|
||||
class IsSystemCustomerFilter(admin.SimpleListFilter):
|
||||
@@ -44,14 +44,14 @@ class CustomerAdmin(admin.ModelAdmin):
|
||||
)
|
||||
date_hierarchy = '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 = (
|
||||
('Основная информация', {
|
||||
'fields': ('name', 'email', 'phone', 'is_system_customer')
|
||||
}),
|
||||
('Кошелёк', {
|
||||
'fields': ('wallet_balance',),
|
||||
'fields': ('wallet_balance_display',),
|
||||
}),
|
||||
('Заметки', {
|
||||
'fields': ('notes',)
|
||||
@@ -64,20 +64,20 @@ class CustomerAdmin(admin.ModelAdmin):
|
||||
|
||||
def wallet_balance_display(self, obj):
|
||||
"""Отображение баланса кошелька с цветом"""
|
||||
if obj.wallet_balance > 0:
|
||||
balance = obj.wallet_balance
|
||||
if balance > 0:
|
||||
return format_html(
|
||||
'<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.admin_order_field = 'wallet_balance'
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""Делаем все поля read-only для системного клиента"""
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class ContactChannelInline(admin.TabularInline):
|
||||
"""Inline для управления каналами связи клиента"""
|
||||
model = ContactChannel
|
||||
extra = 1
|
||||
fields = ('channel_type', 'value', 'is_primary', 'notes')
|
||||
|
||||
|
||||
class WalletTransactionInline(admin.TabularInline):
|
||||
"""
|
||||
line для отображения транзакций кошелька"""
|
||||
"""Inline для отображения транзакций кошелька"""
|
||||
model = WalletTransaction
|
||||
extra = 0
|
||||
can_delete = False
|
||||
readonly_fields = ('transaction_type', 'amount', 'order', 'description', 'created_at', 'created_by')
|
||||
fields = ('created_at', 'transaction_type', 'amount', 'order', 'description', 'created_by')
|
||||
readonly_fields = ('created_at', 'transaction_type', 'signed_amount', 'balance_after', 'order', 'description', 'created_by')
|
||||
fields = ('created_at', 'transaction_type', 'signed_amount', 'balance_after', 'order', 'description', 'created_by')
|
||||
ordering = ('-created_at',)
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
@@ -114,32 +120,33 @@ line для отображения транзакций кошелька"""
|
||||
|
||||
|
||||
# Добавляем inline в CustomerAdmin
|
||||
CustomerAdmin.inlines = [WalletTransactionInline]
|
||||
CustomerAdmin.inlines = [ContactChannelInline, WalletTransactionInline]
|
||||
|
||||
|
||||
@admin.register(WalletTransaction)
|
||||
class WalletTransactionAdmin(admin.ModelAdmin):
|
||||
"""Админка для просмотра всех транзакций кошелька"""
|
||||
list_display = ('created_at', 'customer', 'transaction_type', 'amount_display', 'order', 'created_by')
|
||||
list_filter = ('transaction_type', 'created_at')
|
||||
list_display = ('created_at', 'customer', 'transaction_type', 'amount_display', 'balance_after', 'order', 'created_by')
|
||||
list_filter = ('transaction_type', 'balance_category', 'created_at')
|
||||
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'
|
||||
ordering = ('-created_at',)
|
||||
|
||||
def amount_display(self, obj):
|
||||
"""Отображение суммы с цветом"""
|
||||
if obj.transaction_type == 'deposit':
|
||||
amount = obj.signed_amount
|
||||
if amount > 0:
|
||||
return format_html(
|
||||
'<span style="color: green; font-weight: bold;">+{} руб.</span>',
|
||||
obj.amount
|
||||
amount
|
||||
)
|
||||
elif obj.transaction_type == 'spend':
|
||||
elif amount < 0:
|
||||
return format_html(
|
||||
'<span style="color: red; font-weight: bold;">-{} руб.</span>',
|
||||
obj.amount
|
||||
'<span style="color: red; font-weight: bold;">{} руб.</span>',
|
||||
amount
|
||||
)
|
||||
return f'{obj.amount} руб.'
|
||||
return f'{amount} руб.'
|
||||
amount_display.short_description = 'Сумма'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
|
||||
@@ -2,7 +2,7 @@ from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from phonenumber_field.formfields import PhoneNumberField
|
||||
from phonenumber_field.widgets import PhoneNumberPrefixWidget
|
||||
from .models import Customer
|
||||
from .models import Customer, ContactChannel
|
||||
|
||||
class CustomerForm(forms.ModelForm):
|
||||
phone = PhoneNumberField(
|
||||
@@ -35,43 +35,17 @@ class CustomerForm(forms.ModelForm):
|
||||
field.widget.attrs.update({'class': 'form-control'})
|
||||
|
||||
def clean_email(self):
|
||||
"""Проверяет уникальность email при создании/редактировании"""
|
||||
"""Нормализует пустые значения email в None"""
|
||||
email = self.cleaned_data.get('email')
|
||||
|
||||
# Нормализуем пустые значения в None (Django best practice для nullable полей)
|
||||
if not email:
|
||||
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
|
||||
|
||||
def clean_phone(self):
|
||||
"""Проверяет уникальность телефона при создании/редактировании"""
|
||||
"""Нормализует пустые значения телефона в None"""
|
||||
phone = self.cleaned_data.get('phone')
|
||||
|
||||
# Нормализуем пустые значения в None (Django best practice для nullable полей)
|
||||
if not phone:
|
||||
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
|
||||
|
||||
def clean(self):
|
||||
@@ -86,3 +60,34 @@ class CustomerForm(forms.ModelForm):
|
||||
)
|
||||
|
||||
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
|
||||
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,
|
||||
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)
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
Сервис для работы с кошельком клиента.
|
||||
Обрабатывает пополнения, списания и корректировки баланса.
|
||||
Все операции создают транзакции в WalletTransaction.
|
||||
Баланс вычисляется как SUM(signed_amount).
|
||||
"""
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
|
||||
@@ -20,20 +22,168 @@ def _quantize(value):
|
||||
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
|
||||
@transaction.atomic
|
||||
def pay_with_wallet(order, amount, user):
|
||||
"""
|
||||
Оплата заказа из кошелька клиента.
|
||||
Создаёт транзакцию. Списание из кошелька происходит автоматически в Transaction.save().
|
||||
|
||||
Args:
|
||||
order: Заказ для оплаты
|
||||
amount: Запрашиваемая сумма для списания
|
||||
user: Пользователь, инициировавший операцию
|
||||
amount: Запрашиваемая сумма
|
||||
user: Пользователь
|
||||
|
||||
Returns:
|
||||
Decimal: Фактически списанная сумма или None
|
||||
@@ -41,26 +191,28 @@ class WalletService:
|
||||
from customers.models import Customer
|
||||
from orders.services.transaction_service import TransactionService
|
||||
|
||||
# Округляем запрошенную сумму
|
||||
amount = _quantize(amount)
|
||||
if amount <= 0:
|
||||
return None
|
||||
|
||||
# Блокируем запись клиента для проверки баланса
|
||||
# Блокируем клиента для проверки баланса
|
||||
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
|
||||
|
||||
# Определяем фактическую сумму списания (минимум из трёх)
|
||||
usable_amount = min(amount, customer.wallet_balance, amount_due)
|
||||
# Фактическая сумма (минимум из трёх)
|
||||
usable_amount = min(amount, wallet_balance, amount_due)
|
||||
usable_amount = _quantize(usable_amount)
|
||||
|
||||
if usable_amount <= 0:
|
||||
return None
|
||||
|
||||
# Создаём транзакцию
|
||||
# Transaction.save() автоматически спишет из кошелька и создаст WalletTransaction
|
||||
# Создаём транзакцию платежа
|
||||
# Transaction.save() вызовет create_wallet_spend()
|
||||
TransactionService.create_payment(
|
||||
order=order,
|
||||
amount=usable_amount,
|
||||
@@ -76,12 +228,11 @@ class WalletService:
|
||||
def refund_wallet_payment(order, amount, user):
|
||||
"""
|
||||
Возврат средств в кошелёк.
|
||||
Используется для создания транзакции возврата с кошельком.
|
||||
|
||||
Args:
|
||||
order: Заказ, по которому был платёж
|
||||
order: Заказ
|
||||
amount: Сумма возврата
|
||||
user: Пользователь, инициировавший возврат
|
||||
user: Пользователь
|
||||
|
||||
Returns:
|
||||
Decimal: Возвращённая сумма
|
||||
@@ -93,7 +244,7 @@ class WalletService:
|
||||
return None
|
||||
|
||||
# Создаём транзакцию возврата
|
||||
# Transaction.save() автоматически вернёт в кошелёк и создаст WalletTransaction
|
||||
# Transaction.save() вызовет create_wallet_deposit()
|
||||
TransactionService.create_refund(
|
||||
order=order,
|
||||
amount=amount,
|
||||
@@ -108,51 +259,100 @@ class WalletService:
|
||||
@transaction.atomic
|
||||
def adjust_balance(customer_id, amount, description, user):
|
||||
"""
|
||||
Корректировка баланса кошелька администратором.
|
||||
Может быть как положительной (пополнение), так и отрицательной (списание).
|
||||
Корректировка баланса (обёртка для обратной совместимости).
|
||||
|
||||
Args:
|
||||
customer_id: ID клиента
|
||||
amount: Сумма корректировки (может быть отрицательной)
|
||||
description: Обязательное описание причины корректировки
|
||||
user: Пользователь, выполнивший корректировку
|
||||
amount: Сумма (может быть отрицательной)
|
||||
description: Описание
|
||||
user: Пользователь
|
||||
|
||||
Returns:
|
||||
WalletTransaction: Созданная транзакция
|
||||
WalletTransaction
|
||||
"""
|
||||
from customers.models import Customer, WalletTransaction
|
||||
|
||||
if not description or not description.strip():
|
||||
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,
|
||||
return WalletService.create_adjustment(
|
||||
customer=customer_id,
|
||||
amount=amount,
|
||||
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 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 %}
|
||||
<div class="col-md-12">
|
||||
@@ -436,6 +490,54 @@
|
||||
</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>
|
||||
// Автооткрытие collapse при наличии якоря в URL
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
@@ -14,6 +14,10 @@ urlpatterns = [
|
||||
path('<int:pk>/wallet/deposit/', views.wallet_deposit, name='wallet-deposit'),
|
||||
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
|
||||
path('api/search/', views.api_search_customers, name='api-search-customers'),
|
||||
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':
|
||||
from customers.models import Customer, WalletTransaction
|
||||
|
||||
# Блокируем запись клиента
|
||||
customer = Customer.objects.select_for_update().get(pk=self.order.customer_id)
|
||||
from customers.services.wallet_service import WalletService
|
||||
|
||||
if self.transaction_type == 'payment':
|
||||
# Списание из кошелька
|
||||
if customer.wallet_balance < self.amount:
|
||||
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',
|
||||
WalletService.create_wallet_spend(
|
||||
order=self.order,
|
||||
description=f'Оплата из кошелька по заказу #{self.order.order_number}',
|
||||
created_by=self.created_by
|
||||
amount=self.amount,
|
||||
user=self.created_by
|
||||
)
|
||||
|
||||
elif self.transaction_type == 'refund':
|
||||
# Возврат в кошелёк
|
||||
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='deposit',
|
||||
WalletService.create_wallet_deposit(
|
||||
order=self.order,
|
||||
description=f'Возврат в кошелёк по заказу #{self.order.order_number}',
|
||||
created_by=self.created_by
|
||||
amount=self.amount,
|
||||
user=self.created_by
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user