- Убран черновик как отдельная сущность с процессом финализации - Черновик теперь просто обычный OrderStatus - Удалены кнопки 'Сохранить как черновик' и 'Финализировать черновик' - Унифицирована логика сохранения/обновления заказов для всех статусов Улучшения шаблонов: - Стандартизировано форматирование валюты через floatformat:2 - Исправлено отображение статуса (используется OrderStatus.label и color) - Исправлено отображение способа оплаты (корректное использование ForeignKey) - Добавлены иконки к заголовкам секций для лучшего UX - Удалены избыточные console.log (~160 строк) - Очищены комментарии и улучшена читаемость кода - Убрано использование переменной is_draft в контексте - Добавлена визуальная согласованность между шаблонами заказов
234 lines
9.0 KiB
Python
234 lines
9.0 KiB
Python
"""
|
||
Сервис для работы с кошельком клиента.
|
||
Обрабатывает пополнения, списания и корректировки баланса.
|
||
"""
|
||
from decimal import Decimal, ROUND_HALF_UP
|
||
from django.db import transaction
|
||
|
||
|
||
# Константа для округления до 2 знаков
|
||
QUANTIZE_2D = Decimal('0.01')
|
||
|
||
|
||
def _quantize(value):
|
||
"""Округление до 2 знаков после запятой"""
|
||
if isinstance(value, (int, float)):
|
||
value = Decimal(str(value))
|
||
return value.quantize(QUANTIZE_2D, rounding=ROUND_HALF_UP)
|
||
|
||
|
||
class WalletService:
|
||
"""
|
||
Сервис для управления кошельком клиента.
|
||
Все операции атомарны и блокируют запись клиента для избежания race conditions.
|
||
"""
|
||
|
||
@staticmethod
|
||
@transaction.atomic
|
||
def add_overpayment(order, user):
|
||
"""
|
||
Обработка переплаты по заказу.
|
||
Переносит излишек в кошелёк клиента и нормализует amount_paid заказа.
|
||
|
||
Args:
|
||
order: Заказ с переплатой
|
||
user: Пользователь, инициировавший операцию
|
||
|
||
Returns:
|
||
Decimal: Сумма переплаты или None, если переплаты нет
|
||
"""
|
||
from customers.models import Customer, WalletTransaction
|
||
|
||
overpayment = order.amount_paid - order.total_amount
|
||
if overpayment <= 0:
|
||
return None
|
||
|
||
# Блокируем запись клиента для обновления
|
||
customer = Customer.objects.select_for_update().get(pk=order.customer_id)
|
||
|
||
# Округляем переплату до 2 знаков
|
||
overpayment = _quantize(overpayment)
|
||
|
||
# Увеличиваем баланс кошелька
|
||
customer.wallet_balance = _quantize(customer.wallet_balance + overpayment)
|
||
customer.save(update_fields=['wallet_balance'])
|
||
|
||
# Создаём транзакцию для аудита
|
||
WalletTransaction.objects.create(
|
||
customer=customer,
|
||
amount=overpayment,
|
||
transaction_type='deposit',
|
||
order=order,
|
||
description=f'Переплата по заказу #{order.order_number}',
|
||
created_by=user
|
||
)
|
||
|
||
# Нормализуем amount_paid заказа до total_amount
|
||
order.amount_paid = order.total_amount
|
||
order.save(update_fields=['amount_paid'])
|
||
|
||
return overpayment
|
||
|
||
@staticmethod
|
||
@transaction.atomic
|
||
def pay_with_wallet(order, amount, user):
|
||
"""
|
||
Оплата заказа из кошелька клиента.
|
||
Списывает средства с кошелька и создаёт платёж в заказе.
|
||
|
||
Args:
|
||
order: Заказ для оплаты
|
||
amount: Запрашиваемая сумма для списания
|
||
user: Пользователь, инициировавший операцию
|
||
|
||
Returns:
|
||
Decimal: Фактически списанная сумма или None
|
||
"""
|
||
from customers.models import Customer, WalletTransaction
|
||
from orders.models import Payment, PaymentMethod
|
||
|
||
# Округляем запрошенную сумму
|
||
amount = _quantize(amount)
|
||
if amount <= 0:
|
||
return None
|
||
|
||
# Блокируем запись клиента
|
||
customer = Customer.objects.select_for_update().get(pk=order.customer_id)
|
||
|
||
# Остаток к оплате по заказу
|
||
amount_due = order.total_amount - order.amount_paid
|
||
|
||
# Определяем фактическую сумму списания (минимум из трёх)
|
||
usable_amount = min(amount, customer.wallet_balance, amount_due)
|
||
usable_amount = _quantize(usable_amount)
|
||
|
||
if usable_amount <= 0:
|
||
return None
|
||
|
||
# Получаем способ оплаты "С баланса счёта"
|
||
try:
|
||
payment_method = PaymentMethod.objects.get(code='account_balance')
|
||
except PaymentMethod.DoesNotExist:
|
||
raise ValueError(
|
||
'Способ оплаты "account_balance" не найден. '
|
||
'Запустите команду create_payment_methods.'
|
||
)
|
||
|
||
# Создаём платёж в заказе
|
||
Payment.objects.create(
|
||
order=order,
|
||
amount=usable_amount,
|
||
payment_method=payment_method,
|
||
created_by=user,
|
||
notes='Оплата из кошелька клиента'
|
||
)
|
||
|
||
# Уменьшаем баланс кошелька
|
||
customer.wallet_balance = _quantize(customer.wallet_balance - usable_amount)
|
||
customer.save(update_fields=['wallet_balance'])
|
||
|
||
# Создаём транзакцию для аудита
|
||
WalletTransaction.objects.create(
|
||
customer=customer,
|
||
amount=usable_amount,
|
||
transaction_type='spend',
|
||
order=order,
|
||
description=f'Оплата заказа #{order.order_number} из кошелька',
|
||
created_by=user
|
||
)
|
||
|
||
return usable_amount
|
||
|
||
@staticmethod
|
||
@transaction.atomic
|
||
def refund_wallet_payment(order, amount, user):
|
||
"""
|
||
Возврат средств в кошелёк при удалении платежа.
|
||
Увеличивает баланс кошелька и создаёт транзакцию deposit.
|
||
|
||
Args:
|
||
order: Заказ, по которому был платёж
|
||
amount: Сумма возврата
|
||
user: Пользователь, инициировавший возврат
|
||
|
||
Returns:
|
||
Decimal: Возвращённая сумма
|
||
"""
|
||
from customers.models import Customer, WalletTransaction
|
||
|
||
amount = _quantize(amount)
|
||
if amount <= 0:
|
||
return None
|
||
|
||
# Блокируем запись клиента
|
||
customer = Customer.objects.select_for_update().get(pk=order.customer_id)
|
||
|
||
# Увеличиваем баланс
|
||
customer.wallet_balance = _quantize(customer.wallet_balance + amount)
|
||
customer.save(update_fields=['wallet_balance'])
|
||
|
||
# Создаём транзакцию возврата
|
||
WalletTransaction.objects.create(
|
||
customer=customer,
|
||
amount=amount,
|
||
transaction_type='deposit',
|
||
order=order,
|
||
description=f'Возврат платежа по заказу #{order.order_number}',
|
||
created_by=user
|
||
)
|
||
|
||
return amount
|
||
|
||
@staticmethod
|
||
@transaction.atomic
|
||
def adjust_balance(customer_id, amount, description, user):
|
||
"""
|
||
Корректировка баланса кошелька администратором.
|
||
Может быть как положительной (пополнение), так и отрицательной (списание).
|
||
|
||
Args:
|
||
customer_id: ID клиента
|
||
amount: Сумма корректировки (может быть отрицательной)
|
||
description: Обязательное описание причины корректировки
|
||
user: Пользователь, выполнивший корректировку
|
||
|
||
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('Сумма корректировки не может быть нулевой')
|
||
|
||
# Блокируем запись клиента
|
||
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,
|
||
created_by=user
|
||
)
|
||
|
||
return txn
|