Основные изменения: - Переход от денормализованного поля 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
147 lines
5.5 KiB
Python
147 lines
5.5 KiB
Python
# 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'),
|
||
),
|
||
]
|