Исправлена форма заказа: две колонки и корректная работа кнопки сохранения

- Разделен экран на две колонки: заказ слева, оплата справа
- Форма оплаты вынесена за пределы основной формы заказа (устранена проблема вложенных форм)
- Исправлен метод calculate_total() для сохранения итоговой суммы в БД
- Добавлена модель Transaction для учета платежей и возвратов
- Добавлена модель PaymentMethod для методов оплаты
- Удалена старая модель Payment, заменена на Transaction
- Добавлен TransactionService для управления транзакциями
- Обновлен интерфейс форм оплаты для правой колонки
- Кнопка 'Сохранить изменения' теперь работает корректно
This commit is contained in:
2025-11-29 14:33:23 +03:00
parent 438ca5d515
commit c1351e1f49
14 changed files with 1188 additions and 548 deletions

View 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'))