Исправлена форма заказа: две колонки и корректная работа кнопки сохранения
- Разделен экран на две колонки: заказ слева, оплата справа - Форма оплаты вынесена за пределы основной формы заказа (устранена проблема вложенных форм) - Исправлен метод calculate_total() для сохранения итоговой суммы в БД - Добавлена модель Transaction для учета платежей и возвратов - Добавлена модель PaymentMethod для методов оплаты - Удалена старая модель Payment, заменена на Transaction - Добавлен TransactionService для управления транзакциями - Обновлен интерфейс форм оплаты для правой колонки - Кнопка 'Сохранить изменения' теперь работает корректно
This commit is contained in:
230
myproject/orders/services/transaction_service.py
Normal file
230
myproject/orders/services/transaction_service.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
Сервис для работы с финансовыми транзакциями заказа.
|
||||
Обрабатывает платежи и возвраты.
|
||||
"""
|
||||
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'))
|
||||
Reference in New Issue
Block a user