Исправлена форма заказа: две колонки и корректная работа кнопки сохранения
- Разделен экран на две колонки: заказ слева, оплата справа - Форма оплаты вынесена за пределы основной формы заказа (устранена проблема вложенных форм) - Исправлен метод calculate_total() для сохранения итоговой суммы в БД - Добавлена модель Transaction для учета платежей и возвратов - Добавлена модель PaymentMethod для методов оплаты - Удалена старая модель Payment, заменена на Transaction - Добавлен TransactionService для управления транзакциями - Обновлен интерфейс форм оплаты для правой колонки - Кнопка 'Сохранить изменения' теперь работает корректно
This commit is contained in:
@@ -7,13 +7,13 @@
|
||||
- Order: Главная модель заказа
|
||||
- OrderItem: Позиции в заказе
|
||||
- PaymentMethod: Способы оплаты (справочник)
|
||||
- Payment: Платежи по заказам (поддержка смешанной оплаты)
|
||||
- Transaction: Финансовые транзакции (платежи и возвраты)
|
||||
"""
|
||||
|
||||
# Порядок импортов по зависимостям:
|
||||
# 1. Независимые модели (справочники)
|
||||
from .status import OrderStatus
|
||||
from .payment import PaymentMethod
|
||||
from .payment_method import PaymentMethod
|
||||
|
||||
# 2. Модели с зависимостями от справочников
|
||||
from .address import Address
|
||||
@@ -23,7 +23,7 @@ from .order import Order
|
||||
|
||||
# 4. Зависимые модели
|
||||
from .order_item import OrderItem
|
||||
from .payment import Payment
|
||||
from .transaction import Transaction
|
||||
|
||||
__all__ = [
|
||||
'OrderStatus',
|
||||
@@ -31,5 +31,5 @@ __all__ = [
|
||||
'Order',
|
||||
'OrderItem',
|
||||
'PaymentMethod',
|
||||
'Payment',
|
||||
'Transaction',
|
||||
]
|
||||
|
||||
@@ -317,15 +317,38 @@ class Order(models.Model):
|
||||
self.delivery_cost = DeliveryCostCalculator.calculate(self)
|
||||
|
||||
def calculate_total(self):
|
||||
"""Рассчитывает итоговую сумму заказа"""
|
||||
"""Рассчитывает итоговую сумму заказа и сохраняет её в БД"""
|
||||
items_total = sum(item.get_total_price() for item in self.items.all())
|
||||
|
||||
# Пересчитываем стоимость доставки если она автоматическая
|
||||
self.recalculate_delivery_cost()
|
||||
|
||||
self.total_amount = items_total + self.delivery_cost
|
||||
|
||||
# Сохраняем изменения в БД
|
||||
self.save(update_fields=['total_amount', 'delivery_cost', 'is_custom_delivery_cost'])
|
||||
|
||||
return self.total_amount
|
||||
|
||||
def recalculate_amount_paid(self):
|
||||
"""
|
||||
Пересчитывает оплаченную сумму на основе транзакций.
|
||||
amount_paid = сумма_платежей - сумма_возвратов
|
||||
"""
|
||||
from django.db.models import Sum, Q
|
||||
from decimal import Decimal
|
||||
|
||||
payments_sum = self.transactions.filter(
|
||||
transaction_type='payment'
|
||||
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
|
||||
|
||||
refunds_sum = self.transactions.filter(
|
||||
transaction_type='refund'
|
||||
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
|
||||
|
||||
self.amount_paid = payments_sum - refunds_sum
|
||||
self.update_payment_status()
|
||||
|
||||
def update_payment_status(self):
|
||||
"""Автоматически обновляет статус оплаты на основе amount_paid"""
|
||||
if self.amount_paid >= self.total_amount:
|
||||
@@ -337,7 +360,8 @@ class Order(models.Model):
|
||||
else:
|
||||
self.payment_status = 'unpaid'
|
||||
self.is_paid = False
|
||||
self.save()
|
||||
# Сохраняем только изменённые поля для оптимизации
|
||||
self.save(update_fields=['payment_status', 'is_paid', 'amount_paid'])
|
||||
|
||||
def is_draft(self):
|
||||
"""Проверяет, является ли заказ черновиком"""
|
||||
@@ -348,6 +372,11 @@ class Order(models.Model):
|
||||
"""Остаток к оплате"""
|
||||
return max(self.total_amount - self.amount_paid, 0)
|
||||
|
||||
@property
|
||||
def subtotal(self):
|
||||
"""Сумма только товаров (без доставки)"""
|
||||
return sum(item.get_total_price() for item in self.items.all())
|
||||
|
||||
@property
|
||||
def delivery_cost_display(self):
|
||||
"""
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
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 PaymentMethod(models.Model):
|
||||
"""
|
||||
Способ оплаты заказа.
|
||||
Справочник для управления доступными методами оплаты.
|
||||
"""
|
||||
|
||||
# Код для программного доступа
|
||||
code = models.SlugField(
|
||||
unique=True,
|
||||
max_length=50,
|
||||
verbose_name="Код способа оплаты",
|
||||
help_text="Уникальный идентификатор (например: 'cash_to_courier', 'card_to_courier')"
|
||||
)
|
||||
|
||||
# Отображаемое название
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
verbose_name="Название способа оплаты"
|
||||
)
|
||||
|
||||
# Описание
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Описание",
|
||||
help_text="Дополнительная информация о способе оплаты"
|
||||
)
|
||||
|
||||
# Активность
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name="Активен",
|
||||
help_text="Отключенные способы оплаты не отображаются при создании заказа"
|
||||
)
|
||||
|
||||
# Порядок отображения
|
||||
order = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name="Порядок отображения"
|
||||
)
|
||||
|
||||
# Системный флаг
|
||||
is_system = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Системный",
|
||||
help_text="Системные способы оплаты нельзя удалить через интерфейс"
|
||||
)
|
||||
|
||||
# Аудит
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
created_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='created_payment_methods',
|
||||
verbose_name="Создано"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Способ оплаты"
|
||||
verbose_name_plural = "Способы оплаты"
|
||||
ordering = ['order', 'name']
|
||||
indexes = [
|
||||
models.Index(fields=['code']),
|
||||
models.Index(fields=['is_active']),
|
||||
models.Index(fields=['order']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Payment(models.Model):
|
||||
"""
|
||||
Платеж по заказу.
|
||||
Хранит историю всех платежей, включая частичные оплаты.
|
||||
Поддерживает смешанную оплату (несколько платежей разными способами на один заказ).
|
||||
"""
|
||||
order = models.ForeignKey(
|
||||
'Order',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='payments',
|
||||
verbose_name="Заказ"
|
||||
)
|
||||
|
||||
amount = models.DecimalField(
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
verbose_name="Сумма платежа"
|
||||
)
|
||||
|
||||
payment_method = models.ForeignKey(
|
||||
'PaymentMethod',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='payments',
|
||||
verbose_name="Способ оплаты",
|
||||
help_text="Способ оплаты данного платежа"
|
||||
)
|
||||
|
||||
payment_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='payments_created',
|
||||
verbose_name="Принял платеж"
|
||||
)
|
||||
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Примечания",
|
||||
help_text="Дополнительная информация о платеже"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Платеж"
|
||||
verbose_name_plural = "Платежи"
|
||||
ordering = ['-payment_date']
|
||||
indexes = [
|
||||
models.Index(fields=['order']),
|
||||
models.Index(fields=['payment_date']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Платеж {self.amount} руб. по заказу #{self.order.order_number}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""При сохранении платежа обновляем сумму оплаты в заказе и обрабатываем кошелёк/переплаты"""
|
||||
is_new = self.pk is None
|
||||
with transaction.atomic():
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Пересчитываем общую сумму оплаты в заказе
|
||||
self.order.amount_paid = sum(p.amount for p in self.order.payments.all())
|
||||
self.order.update_payment_status()
|
||||
|
||||
# Списание из кошелька при новом платеже методом 'account_balance'
|
||||
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 customer.wallet_balance < self.amount:
|
||||
raise ValidationError(f'Недостаточно средств в кошельке (доступно {customer.wallet_balance} руб.)')
|
||||
|
||||
# Списываем и округляем до 2 знаков
|
||||
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
|
||||
)
|
||||
|
||||
# Нормализация переплаты: лишнее в кошелёк, amount_paid = total_amount
|
||||
# ТОЛЬКО для новых платежей, чтобы избежать дублирования при обновлении
|
||||
if is_new:
|
||||
try:
|
||||
from customers.services.wallet_service import WalletService
|
||||
WalletService.add_overpayment(self.order, self.created_by)
|
||||
except Exception:
|
||||
# Продолжаем, даже если нормализация переплаты не удалась
|
||||
pass
|
||||
76
myproject/orders/models/payment_method.py
Normal file
76
myproject/orders/models/payment_method.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from django.db import models
|
||||
from accounts.models import CustomUser
|
||||
|
||||
|
||||
class PaymentMethod(models.Model):
|
||||
"""
|
||||
Способ оплаты заказа.
|
||||
Справочник для управления доступными методами оплаты.
|
||||
"""
|
||||
|
||||
# Код для программного доступа
|
||||
code = models.SlugField(
|
||||
unique=True,
|
||||
max_length=50,
|
||||
verbose_name="Код способа оплаты",
|
||||
help_text="Уникальный идентификатор (например: 'cash_to_courier', 'card_to_courier')"
|
||||
)
|
||||
|
||||
# Отображаемое название
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
verbose_name="Название способа оплаты"
|
||||
)
|
||||
|
||||
# Описание
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Описание",
|
||||
help_text="Дополнительная информация о способе оплаты"
|
||||
)
|
||||
|
||||
# Активность
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name="Активен",
|
||||
help_text="Отключенные способы оплаты не отображаются при создании заказа"
|
||||
)
|
||||
|
||||
# Порядок отображения
|
||||
order = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name="Порядок отображения"
|
||||
)
|
||||
|
||||
# Системный флаг
|
||||
is_system = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Системный",
|
||||
help_text="Системные способы оплаты нельзя удалить через интерфейс"
|
||||
)
|
||||
|
||||
# Аудит
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
created_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='created_payment_methods',
|
||||
verbose_name="Создано"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Способ оплаты"
|
||||
verbose_name_plural = "Способы оплаты"
|
||||
ordering = ['order', 'name']
|
||||
indexes = [
|
||||
models.Index(fields=['code']),
|
||||
models.Index(fields=['is_active']),
|
||||
models.Index(fields=['order']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
182
myproject/orders/models/transaction.py
Normal file
182
myproject/orders/models/transaction.py
Normal 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
|
||||
Reference in New Issue
Block a user