Files
octopus/myproject/customers/migrations/0004_wallet_refactoring.py
Andrey Smakotin b1855cc9f0 Рефакторинг системы кошелька клиентов
Основные изменения:
- Переход от денормализованного поля wallet_balance к вычисляемому балансу
- Баланс теперь вычисляется как SUM(signed_amount) транзакций
- Добавлено кеширование баланса для производительности (5 минут)
- Новая модель WalletTransaction с полем signed_amount (может быть +/-)
- WalletService для всех операций с кошельком (deposit, spend, adjustment)
- Защита от отрицательного баланса и race conditions через select_for_update
- Добавлен balance_after в каждую транзакцию для аудита
- Обновлены миграции для переноса данных из старой схемы

Улучшения безопасности:
- Атомарные транзакции для всех операций с балансом
- Блокировка строк при модификации баланса
- Валидация недостаточности средств
- Обязательное описание для корректировок баланса

UI/UX изменения:
- Обновлён вывод баланса кошелька в деталях клиента
- Добавлена история транзакций с типами и описаниями
- Цветовая индикация положительных транзакций (зелёный)

Техническая документация:
- Добавлены docstrings для всех методов WalletService
- Комментарии к критичным участкам кода
- Примеры использования в docstrings
2025-12-28 00:02:09 +03:00

147 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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'),
),
]