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

- Разделен экран на две колонки: заказ слева, оплата справа
- Форма оплаты вынесена за пределы основной формы заказа (устранена проблема вложенных форм)
- Исправлен метод 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,182 @@
from django.db import models
from accounts.models import CustomUser
from decimal import Decimal
from django.db import transaction
from django.core.exceptions import ValidationError
class Transaction(models.Model):
"""
Финансовая транзакция по заказу.
Хранит историю всех платежей и возвратов.
Транзакция может быть:
- payment: клиент платит (увеличивает amount_paid)
- refund: возврат клиенту (уменьшает amount_paid)
Поддерживает:
- Смешанную оплату (несколько транзакций разными способами)
- Частичные возвраты (любая сумма)
- Полную историю движения денег
"""
TRANSACTION_TYPE_CHOICES = [
('payment', 'Платёж'),
('refund', 'Возврат'),
]
order = models.ForeignKey(
'Order',
on_delete=models.CASCADE,
related_name='transactions',
verbose_name="Заказ"
)
transaction_type = models.CharField(
max_length=20,
choices=TRANSACTION_TYPE_CHOICES,
default='payment',
verbose_name="Тип транзакции"
)
amount = models.DecimalField(
max_digits=10,
decimal_places=2,
verbose_name="Сумма",
help_text="Всегда положительная. Для возврата используется transaction_type='refund'"
)
payment_method = models.ForeignKey(
'PaymentMethod',
on_delete=models.PROTECT,
related_name='transactions',
verbose_name="Способ оплаты/возврата"
)
# Для возвратов - опциональная связь с исходным платежом
related_payment = models.ForeignKey(
'self',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='refunds',
verbose_name="Связанный платёж",
help_text="Для возвратов - на какой платёж ссылается этот возврат"
)
transaction_date = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата и время транзакции"
)
created_by = models.ForeignKey(
CustomUser,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='transactions_created',
verbose_name="Создал"
)
notes = models.TextField(
blank=True,
null=True,
verbose_name="Примечания"
)
reason = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name="Причина",
help_text="Причина возврата или особенности платежа"
)
class Meta:
verbose_name = "Транзакция"
verbose_name_plural = "Транзакции"
ordering = ['-transaction_date']
indexes = [
models.Index(fields=['order', '-transaction_date']),
models.Index(fields=['transaction_type']),
models.Index(fields=['payment_method']),
models.Index(fields=['transaction_date']),
]
def __str__(self):
type_name = self.get_transaction_type_display()
return f"{type_name} {self.amount} руб. по заказу #{self.order.order_number}"
def clean(self):
"""Валидация транзакции"""
super().clean()
# Сумма всегда положительная
if self.amount <= 0:
raise ValidationError({'amount': 'Сумма должна быть положительной'})
# Для возврата related_payment должен быть payment
if self.transaction_type == 'refund' and self.related_payment:
if self.related_payment.transaction_type != 'payment':
raise ValidationError({
'related_payment': 'Связанная транзакция должна быть платежом'
})
def save(self, *args, **kwargs):
"""При сохранении обновляем баланс заказа и обрабатываем кошелёк"""
is_new = self.pk is None
with transaction.atomic():
super().save(*args, **kwargs)
# Пересчитываем баланс заказа
self.order.recalculate_amount_paid()
# Обработка кошелька только для новых транзакций
if is_new and self.payment_method.code == 'account_balance':
from customers.models import Customer, WalletTransaction
# Блокируем запись клиента
customer = Customer.objects.select_for_update().get(pk=self.order.customer_id)
if self.transaction_type == 'payment':
# Списание из кошелька
if customer.wallet_balance < self.amount:
raise ValidationError(
f'Недостаточно средств в кошельке '
f'(доступно {customer.wallet_balance} руб.)'
)
customer.wallet_balance = (customer.wallet_balance - self.amount).quantize(Decimal('0.01'))
customer.save(update_fields=['wallet_balance'])
WalletTransaction.objects.create(
customer=customer,
amount=self.amount,
transaction_type='spend',
order=self.order,
description=f'Оплата из кошелька по заказу #{self.order.order_number}',
created_by=self.created_by
)
elif self.transaction_type == 'refund':
# Возврат в кошелёк
customer.wallet_balance = (customer.wallet_balance + self.amount).quantize(Decimal('0.01'))
customer.save(update_fields=['wallet_balance'])
WalletTransaction.objects.create(
customer=customer,
amount=self.amount,
transaction_type='deposit',
order=self.order,
description=f'Возврат в кошелёк по заказу #{self.order.order_number}',
created_by=self.created_by
)
# Обработка переплаты (только для payment)
if is_new and self.transaction_type == 'payment':
try:
from customers.services.wallet_service import WalletService
WalletService.add_overpayment(self.order, self.created_by)
except Exception:
pass