Рефакторинг системы кошелька клиентов

Основные изменения:
- Переход от денормализованного поля 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:
2025-12-28 00:02:09 +03:00
parent 65b3055755
commit b1855cc9f0
9 changed files with 800 additions and 170 deletions

View File

@@ -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() {