- Разделен экран на две колонки: заказ слева, оплата справа - Форма оплаты вынесена за пределы основной формы заказа (устранена проблема вложенных форм) - Исправлен метод calculate_total() для сохранения итоговой суммы в БД - Добавлена модель Transaction для учета платежей и возвратов - Добавлена модель PaymentMethod для методов оплаты - Удалена старая модель Payment, заменена на Transaction - Добавлен TransactionService для управления транзакциями - Обновлен интерфейс форм оплаты для правой колонки - Кнопка 'Сохранить изменения' теперь работает корректно
231 lines
8.6 KiB
Python
231 lines
8.6 KiB
Python
"""
|
||
Сервис для работы с финансовыми транзакциями заказа.
|
||
Обрабатывает платежи и возвраты.
|
||
"""
|
||
from decimal import Decimal, ROUND_HALF_UP
|
||
from django.db import transaction
|
||
from django.core.exceptions import ValidationError
|
||
|
||
|
||
# Константа для округления до 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 TransactionService:
|
||
"""
|
||
Сервис для управления финансовыми транзакциями заказа.
|
||
Все операции атомарны.
|
||
"""
|
||
|
||
@staticmethod
|
||
@transaction.atomic
|
||
def create_payment(order, amount, payment_method, user, notes=None):
|
||
"""
|
||
Создать платёж по заказу.
|
||
|
||
Args:
|
||
order: Заказ для оплаты
|
||
amount: Сумма платежа
|
||
payment_method: Способ оплаты (PaymentMethod или code)
|
||
user: Пользователь, создающий платёж
|
||
notes: Примечания
|
||
|
||
Returns:
|
||
Transaction: Созданная транзакция платежа
|
||
"""
|
||
from orders.models import Transaction, PaymentMethod
|
||
|
||
# Округляем сумму
|
||
amount = _quantize(amount)
|
||
if amount <= 0:
|
||
raise ValueError('Сумма платежа должна быть положительной')
|
||
|
||
# Получаем PaymentMethod если передан code
|
||
if isinstance(payment_method, str):
|
||
try:
|
||
payment_method = PaymentMethod.objects.get(code=payment_method)
|
||
except PaymentMethod.DoesNotExist:
|
||
raise ValueError(f'Способ оплаты "{payment_method}" не найден')
|
||
|
||
# Создаём транзакцию
|
||
txn = Transaction.objects.create(
|
||
order=order,
|
||
transaction_type='payment',
|
||
amount=amount,
|
||
payment_method=payment_method,
|
||
created_by=user,
|
||
notes=notes or f'Платёж {payment_method.name}'
|
||
)
|
||
|
||
return txn
|
||
|
||
@staticmethod
|
||
@transaction.atomic
|
||
def create_refund(order, amount, payment_method, user, reason=None, notes=None, related_payment=None):
|
||
"""
|
||
Создать возврат по заказу.
|
||
|
||
Args:
|
||
order: Заказ для возврата
|
||
amount: Сумма возврата
|
||
payment_method: Способ возврата (PaymentMethod или code)
|
||
user: Пользователь, создающий возврат
|
||
reason: Причина возврата
|
||
notes: Примечания
|
||
related_payment: Связанный платёж (Transaction ID или объект)
|
||
|
||
Returns:
|
||
Transaction: Созданная транзакция возврата
|
||
"""
|
||
from orders.models import Transaction, PaymentMethod
|
||
|
||
# Округляем сумму
|
||
amount = _quantize(amount)
|
||
if amount <= 0:
|
||
raise ValueError('Сумма возврата должна быть положительной')
|
||
|
||
# Проверяем, что не возвращаем больше чем оплачено
|
||
if amount > order.amount_paid:
|
||
raise ValidationError(
|
||
f'Сумма возврата ({amount}) превышает оплаченную сумму ({order.amount_paid})'
|
||
)
|
||
|
||
# Получаем PaymentMethod если передан code
|
||
if isinstance(payment_method, str):
|
||
try:
|
||
payment_method = PaymentMethod.objects.get(code=payment_method)
|
||
except PaymentMethod.DoesNotExist:
|
||
raise ValueError(f'Способ оплаты "{payment_method}" не найден')
|
||
|
||
# Получаем related_payment если передан ID
|
||
if isinstance(related_payment, int):
|
||
try:
|
||
related_payment = Transaction.objects.get(pk=related_payment, transaction_type='payment')
|
||
except Transaction.DoesNotExist:
|
||
raise ValueError(f'Платёж #{related_payment} не найден')
|
||
|
||
# Создаём транзакцию возврата
|
||
txn = Transaction.objects.create(
|
||
order=order,
|
||
transaction_type='refund',
|
||
amount=amount,
|
||
payment_method=payment_method,
|
||
related_payment=related_payment,
|
||
reason=reason or 'Возврат средств',
|
||
notes=notes,
|
||
created_by=user
|
||
)
|
||
|
||
return txn
|
||
|
||
@staticmethod
|
||
@transaction.atomic
|
||
def create_full_refund(order, user, reason=None):
|
||
"""
|
||
Полный возврат всей оплаченной суммы заказа.
|
||
Возвращает средства пропорционально платежам (LIFO - последний вошёл, первый вышел).
|
||
|
||
Args:
|
||
order: Заказ для возврата
|
||
user: Пользователь, создающий возврат
|
||
reason: Причина возврата
|
||
|
||
Returns:
|
||
list[Transaction]: Список созданных транзакций возврата
|
||
"""
|
||
from orders.models import Transaction
|
||
|
||
if order.amount_paid <= 0:
|
||
raise ValidationError('Нет средств для возврата')
|
||
|
||
refunds = []
|
||
remaining_to_refund = order.amount_paid
|
||
|
||
# Берём платежи в обратном порядке (LIFO)
|
||
payments = order.transactions.filter(transaction_type='payment').order_by('-transaction_date')
|
||
|
||
for payment in payments:
|
||
if remaining_to_refund <= 0:
|
||
break
|
||
|
||
# Сколько уже вернули по этому платежу
|
||
from django.db.models import Sum
|
||
already_refunded = payment.refunds.aggregate(
|
||
total=Sum('amount')
|
||
)['total'] or Decimal('0')
|
||
|
||
# Сколько можно вернуть по этому платежу
|
||
available_for_refund = payment.amount - already_refunded
|
||
|
||
if available_for_refund <= 0:
|
||
continue
|
||
|
||
# Возвращаем минимум из доступного и оставшегося
|
||
refund_amount = min(available_for_refund, remaining_to_refund)
|
||
|
||
# Создаём возврат
|
||
refund = TransactionService.create_refund(
|
||
order=order,
|
||
amount=refund_amount,
|
||
payment_method=payment.payment_method,
|
||
user=user,
|
||
reason=reason or 'Полный возврат заказа',
|
||
related_payment=payment
|
||
)
|
||
|
||
refunds.append(refund)
|
||
remaining_to_refund -= refund_amount
|
||
|
||
return refunds
|
||
|
||
@staticmethod
|
||
def get_payment_summary(order):
|
||
"""
|
||
Получить сводку по платежам и возвратам заказа.
|
||
|
||
Args:
|
||
order: Заказ
|
||
|
||
Returns:
|
||
dict: Сводка с суммами платежей, возвратов и балансом
|
||
"""
|
||
from django.db.models import Sum
|
||
from decimal import Decimal
|
||
|
||
payments_sum = order.transactions.filter(
|
||
transaction_type='payment'
|
||
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
|
||
|
||
refunds_sum = order.transactions.filter(
|
||
transaction_type='refund'
|
||
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
|
||
|
||
return {
|
||
'payments_total': payments_sum,
|
||
'refunds_total': refunds_sum,
|
||
'amount_paid': payments_sum - refunds_sum,
|
||
'amount_due': max(order.total_amount - (payments_sum - refunds_sum), Decimal('0')),
|
||
'payments_count': order.transactions.filter(transaction_type='payment').count(),
|
||
'refunds_count': order.transactions.filter(transaction_type='refund').count(),
|
||
}
|
||
|
||
@staticmethod
|
||
def get_refundable_amount(order):
|
||
"""
|
||
Получить сумму, доступную для возврата.
|
||
|
||
Args:
|
||
order: Заказ
|
||
|
||
Returns:
|
||
Decimal: Сумма, которую можно вернуть
|
||
"""
|
||
return max(order.amount_paid, Decimal('0'))
|