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

- Разделен экран на две колонки: заказ слева, оплата справа
- Форма оплаты вынесена за пределы основной формы заказа (устранена проблема вложенных форм)
- Исправлен метод 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

@@ -74,7 +74,7 @@ class WalletService:
def pay_with_wallet(order, amount, user): def pay_with_wallet(order, amount, user):
""" """
Оплата заказа из кошелька клиента. Оплата заказа из кошелька клиента.
Списывает средства с кошелька и создаёт платёж в заказе. Создаёт транзакцию. Списание из кошелька происходит автоматически в Transaction.save().
Args: Args:
order: Заказ для оплаты order: Заказ для оплаты
@@ -84,15 +84,15 @@ class WalletService:
Returns: Returns:
Decimal: Фактически списанная сумма или None Decimal: Фактически списанная сумма или None
""" """
from customers.models import Customer, WalletTransaction from customers.models import Customer
from orders.models import Payment, PaymentMethod from orders.services.transaction_service import TransactionService
# Округляем запрошенную сумму # Округляем запрошенную сумму
amount = _quantize(amount) amount = _quantize(amount)
if amount <= 0: if amount <= 0:
return None return None
# Блокируем запись клиента # Блокируем запись клиента для проверки баланса
customer = Customer.objects.select_for_update().get(pk=order.customer_id) customer = Customer.objects.select_for_update().get(pk=order.customer_id)
# Остаток к оплате по заказу # Остаток к оплате по заказу
@@ -105,46 +105,24 @@ class WalletService:
if usable_amount <= 0: if usable_amount <= 0:
return None return None
# Получаем способ оплаты "С баланса счёта" # Создаём транзакцию
try: # Transaction.save() автоматически спишет из кошелька и создаст WalletTransaction
payment_method = PaymentMethod.objects.get(code='account_balance') TransactionService.create_payment(
except PaymentMethod.DoesNotExist:
raise ValueError(
'Способ оплаты "account_balance" не найден. '
'Запустите команду create_payment_methods.'
)
# Создаём платёж в заказе
Payment.objects.create(
order=order, order=order,
amount=usable_amount, amount=usable_amount,
payment_method=payment_method, payment_method='account_balance',
created_by=user, user=user,
notes='Оплата из кошелька клиента' notes='Оплата из кошелька клиента'
) )
# Уменьшаем баланс кошелька
customer.wallet_balance = _quantize(customer.wallet_balance - usable_amount)
customer.save(update_fields=['wallet_balance'])
# Создаём транзакцию для аудита
WalletTransaction.objects.create(
customer=customer,
amount=usable_amount,
transaction_type='spend',
order=order,
description=f'Оплата заказа #{order.order_number} из кошелька',
created_by=user
)
return usable_amount return usable_amount
@staticmethod @staticmethod
@transaction.atomic @transaction.atomic
def refund_wallet_payment(order, amount, user): def refund_wallet_payment(order, amount, user):
""" """
Возврат средств в кошелёк при удалении платежа. Возврат средств в кошелёк.
Увеличивает баланс кошелька и создаёт транзакцию deposit. Используется для создания транзакции возврата с кошельком.
Args: Args:
order: Заказ, по которому был платёж order: Заказ, по которому был платёж
@@ -154,27 +132,20 @@ class WalletService:
Returns: Returns:
Decimal: Возвращённая сумма Decimal: Возвращённая сумма
""" """
from customers.models import Customer, WalletTransaction from orders.services.transaction_service import TransactionService
amount = _quantize(amount) amount = _quantize(amount)
if amount <= 0: if amount <= 0:
return None return None
# Блокируем запись клиента
customer = Customer.objects.select_for_update().get(pk=order.customer_id)
# Увеличиваем баланс
customer.wallet_balance = _quantize(customer.wallet_balance + amount)
customer.save(update_fields=['wallet_balance'])
# Создаём транзакцию возврата # Создаём транзакцию возврата
WalletTransaction.objects.create( # Transaction.save() автоматически вернёт в кошелёк и создаст WalletTransaction
customer=customer, TransactionService.create_refund(
amount=amount,
transaction_type='deposit',
order=order, order=order,
description=f'Возврат платежа по заказу #{order.order_number}', amount=amount,
created_by=user payment_method='account_balance',
user=user,
reason='Возврат в кошелёк'
) )
return amount return amount

View File

@@ -1,17 +1,18 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from django.contrib import admin from django.contrib import admin
from django.utils.html import format_html from django.utils.html import format_html
from .models import Order, OrderItem, Payment, PaymentMethod, Address, OrderStatus from .models import Order, OrderItem, Transaction, PaymentMethod, Address, OrderStatus
class PaymentInline(admin.TabularInline): class TransactionInline(admin.TabularInline):
""" """
Inline для управления платежами по заказу. Inline для управления транзакциями по заказу.
""" """
model = Payment model = Transaction
extra = 1 extra = 0
fields = ['amount', 'payment_method', 'payment_date', 'created_by', 'notes'] fields = ['transaction_type', 'amount', 'payment_method', 'transaction_date', 'created_by', 'reason', 'notes']
readonly_fields = ['payment_date'] readonly_fields = ['transaction_date']
can_delete = False # Используйте refund вместо удаления
class OrderItemInline(admin.TabularInline): class OrderItemInline(admin.TabularInline):
@@ -110,7 +111,7 @@ class OrderAdmin(admin.ModelAdmin):
}), }),
) )
inlines = [OrderItemInline, PaymentInline] inlines = [OrderItemInline, TransactionInline]
actions = [ actions = [
'mark_as_confirmed', 'mark_as_confirmed',
@@ -151,37 +152,41 @@ class OrderAdmin(admin.ModelAdmin):
mark_as_paid.short_description = 'Отметить как оплаченные' mark_as_paid.short_description = 'Отметить как оплаченные'
@admin.register(Payment) @admin.register(Transaction)
class PaymentAdmin(admin.ModelAdmin): class TransactionAdmin(admin.ModelAdmin):
""" """
Админ-панель для управления платежами. Админ-панель для управления транзакциями.
""" """
list_display = [ list_display = [
'order', 'order',
'transaction_type',
'amount', 'amount',
'payment_method', 'payment_method',
'payment_date', 'transaction_date',
'created_by', 'created_by',
'reason',
] ]
list_filter = [ list_filter = [
'transaction_type',
'payment_method', 'payment_method',
'payment_date', 'transaction_date',
] ]
search_fields = [ search_fields = [
'order__order_number', 'order__order_number',
'reason',
'notes', 'notes',
] ]
readonly_fields = ['payment_date'] readonly_fields = ['transaction_date']
fieldsets = ( fieldsets = (
('Информация о платеже', { ('Информация о транзакции', {
'fields': ('order', 'amount', 'payment_method', 'payment_date') 'fields': ('order', 'transaction_type', 'amount', 'payment_method', 'transaction_date')
}), }),
('Дополнительно', { ('Дополнительно', {
'fields': ('created_by', 'notes') 'fields': ('related_payment', 'reason', 'created_by', 'notes')
}), }),
) )

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from django import forms from django import forms
from django.forms import inlineformset_factory from django.forms import inlineformset_factory
from .models import Order, OrderItem, Payment, Address, OrderStatus from .models import Order, OrderItem, Transaction, Address, OrderStatus
from customers.models import Customer from customers.models import Customer
from inventory.models import Warehouse from inventory.models import Warehouse
from products.models import Product, ProductKit from products.models import Product, ProductKit
@@ -472,15 +472,15 @@ TemporaryKitItemFormSet = formset_factory(
) )
# === ПЛАТЕЖИ (СМЕШАННАЯ ОПЛАТА) === # === ТРАНЗАКЦИИ (СМЕШАННАЯ ОПЛАТА И ВОЗВРАТЫ) ===
class PaymentForm(forms.ModelForm): class TransactionForm(forms.ModelForm):
""" """
Форма для создания платежа по заказу. Форма для создания транзакций по заказу.
Поддерживает смешанную оплату (несколько платежей на один заказ). Поддерживает смешанную оплату и возвраты.
""" """
class Meta: class Meta:
model = Payment model = Transaction
fields = ['payment_method', 'amount', 'notes'] fields = ['payment_method', 'amount', 'notes']
widgets = { widgets = {
'payment_method': forms.Select(attrs={'class': 'form-select'}), 'payment_method': forms.Select(attrs={'class': 'form-select'}),
@@ -546,13 +546,13 @@ class PaymentForm(forms.ModelForm):
return cleaned return cleaned
# Formset для множественных платежей # Formset для множественных транзакций
PaymentFormSet = inlineformset_factory( TransactionFormSet = inlineformset_factory(
Order, Order,
Payment, Transaction,
form=PaymentForm, form=TransactionForm,
extra=0, # Без пустых форм (добавляем через JavaScript) extra=0, # Без пустых форм (добавляем через JavaScript)
can_delete=True, can_delete=False, # Используйте refund вместо удаления
min_num=0, # Платежи не обязательны при создании черновика min_num=0, # Транзакции не обязательны при создании черновика
validate_min=False, validate_min=False,
) )

View File

@@ -0,0 +1,55 @@
# Generated by Django 5.0.10 on 2025-11-29 09:42
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0005_remove_historicalorder_discount_amount_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Transaction',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('transaction_type', models.CharField(choices=[('payment', 'Платёж'), ('refund', 'Возврат')], default='payment', max_length=20, verbose_name='Тип транзакции')),
('amount', models.DecimalField(decimal_places=2, help_text="Всегда положительная. Для возврата используется transaction_type='refund'", max_digits=10, verbose_name='Сумма')),
('transaction_date', models.DateTimeField(auto_now_add=True, verbose_name='Дата и время транзакции')),
('notes', models.TextField(blank=True, null=True, verbose_name='Примечания')),
('reason', models.CharField(blank=True, help_text='Причина возврата или особенности платежа', max_length=255, null=True, verbose_name='Причина')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='orders.order', verbose_name='Заказ')),
('payment_method', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions', to='orders.paymentmethod', verbose_name='Способ оплаты/возврата')),
('related_payment', models.ForeignKey(blank=True, help_text='Для возвратов - на какой платёж ссылается этот возврат', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refunds', to='orders.transaction', verbose_name='Связанный платёж')),
],
options={
'verbose_name': 'Транзакция',
'verbose_name_plural': 'Транзакции',
'ordering': ['-transaction_date'],
},
),
migrations.DeleteModel(
name='Payment',
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['order', '-transaction_date'], name='orders_tran_order_i_dc90ee_idx'),
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['transaction_type'], name='orders_tran_transac_3d971d_idx'),
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['payment_method'], name='orders_tran_payment_7e354c_idx'),
),
migrations.AddIndex(
model_name='transaction',
index=models.Index(fields=['transaction_date'], name='orders_tran_transac_1bae48_idx'),
),
]

View File

@@ -7,13 +7,13 @@
- Order: Главная модель заказа - Order: Главная модель заказа
- OrderItem: Позиции в заказе - OrderItem: Позиции в заказе
- PaymentMethod: Способы оплаты (справочник) - PaymentMethod: Способы оплаты (справочник)
- Payment: Платежи по заказам (поддержка смешанной оплаты) - Transaction: Финансовые транзакции (платежи и возвраты)
""" """
# Порядок импортов по зависимостям: # Порядок импортов по зависимостям:
# 1. Независимые модели (справочники) # 1. Независимые модели (справочники)
from .status import OrderStatus from .status import OrderStatus
from .payment import PaymentMethod from .payment_method import PaymentMethod
# 2. Модели с зависимостями от справочников # 2. Модели с зависимостями от справочников
from .address import Address from .address import Address
@@ -23,7 +23,7 @@ from .order import Order
# 4. Зависимые модели # 4. Зависимые модели
from .order_item import OrderItem from .order_item import OrderItem
from .payment import Payment from .transaction import Transaction
__all__ = [ __all__ = [
'OrderStatus', 'OrderStatus',
@@ -31,5 +31,5 @@ __all__ = [
'Order', 'Order',
'OrderItem', 'OrderItem',
'PaymentMethod', 'PaymentMethod',
'Payment', 'Transaction',
] ]

View File

@@ -317,15 +317,38 @@ class Order(models.Model):
self.delivery_cost = DeliveryCostCalculator.calculate(self) self.delivery_cost = DeliveryCostCalculator.calculate(self)
def calculate_total(self): def calculate_total(self):
"""Рассчитывает итоговую сумму заказа""" """Рассчитывает итоговую сумму заказа и сохраняет её в БД"""
items_total = sum(item.get_total_price() for item in self.items.all()) items_total = sum(item.get_total_price() for item in self.items.all())
# Пересчитываем стоимость доставки если она автоматическая # Пересчитываем стоимость доставки если она автоматическая
self.recalculate_delivery_cost() self.recalculate_delivery_cost()
self.total_amount = items_total + self.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 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): def update_payment_status(self):
"""Автоматически обновляет статус оплаты на основе amount_paid""" """Автоматически обновляет статус оплаты на основе amount_paid"""
if self.amount_paid >= self.total_amount: if self.amount_paid >= self.total_amount:
@@ -337,7 +360,8 @@ class Order(models.Model):
else: else:
self.payment_status = 'unpaid' self.payment_status = 'unpaid'
self.is_paid = False self.is_paid = False
self.save() # Сохраняем только изменённые поля для оптимизации
self.save(update_fields=['payment_status', 'is_paid', 'amount_paid'])
def is_draft(self): def is_draft(self):
"""Проверяет, является ли заказ черновиком""" """Проверяет, является ли заказ черновиком"""
@@ -348,6 +372,11 @@ class Order(models.Model):
"""Остаток к оплате""" """Остаток к оплате"""
return max(self.total_amount - self.amount_paid, 0) 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 @property
def delivery_cost_display(self): def delivery_cost_display(self):
""" """

View File

@@ -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

View 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

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

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

View File

@@ -296,15 +296,7 @@
<div class="card-body"> <div class="card-body">
<div class="row mb-2"> <div class="row mb-2">
<div class="col-6"><strong>Товары:</strong></div> <div class="col-6"><strong>Товары:</strong></div>
<div class="col-6 text-end"> <div class="col-6 text-end">{{ order.subtotal|floatformat:2 }} руб.</div>
{% with items_total=order.items.all|length %}
{% if items_total > 0 %}
{{ order.total_amount|floatformat:2 }} руб.
{% else %}
0.00 руб.
{% endif %}
{% endwith %}
</div>
</div> </div>
{% if order.is_delivery %} {% if order.is_delivery %}
<div class="row mb-2"> <div class="row mb-2">
@@ -340,25 +332,38 @@
</div> </div>
</div> </div>
<!-- История платежей --> <!-- История транзакций -->
{% if order.payments.all %} {% if order.transactions.exists %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">История платежей</h5> <h5 class="mb-0">История транзакций</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
{% for payment in order.payments.all %} {% for transaction in order.transactions.all|dictsortreversed:"transaction_date" %}
<li class="list-group-item"> <li class="list-group-item">
<div><strong>{{ payment.amount|floatformat:2 }} руб.</strong></div> <div class="d-flex justify-content-between align-items-start">
<div>
<strong class="{% if transaction.transaction_type == 'payment' %}text-success{% else %}text-danger{% endif %}">
{% if transaction.transaction_type == 'refund' %}{% else %}+{% endif %}{{ transaction.amount|floatformat:2 }} руб.
</strong>
</div>
<div>
{% if transaction.transaction_type == 'payment' %}
<span class="badge bg-success">Платёж</span>
{% else %}
<span class="badge bg-warning text-dark">Возврат</span>
{% endif %}
</div>
</div>
<small class="text-muted"> <small class="text-muted">
{{ payment.payment_date|date:"d.m.Y H:i" }}<br> {{ transaction.transaction_date|date:"d.m.Y H:i" }}<br>
{{ payment.payment_method.name }} {{ transaction.payment_method.name }}
{% if payment.notes %} {% if transaction.notes or transaction.reason %}
<br><em>{{ payment.notes }}</em> <br><em>{{ transaction.notes|default:transaction.reason }}</em>
{% endif %} {% endif %}
{% if payment.created_by %} {% if transaction.created_by %}
<br>Принял: {{ payment.created_by.get_full_name }} <br>Кем: {{ transaction.created_by.get_short_name|default:transaction.created_by.username }}
{% endif %} {% endif %}
</small> </small>
</li> </li>

View File

@@ -114,91 +114,95 @@
<form method="post" id="order-form"> <form method="post" id="order-form">
{% csrf_token %} {% csrf_token %}
<!-- Основная информация --> <!-- Две колонки -->
<div class="card mb-3"> <div class="row">
<div class="card-header"> <!-- Левая колонка: Заказ -->
<h5 class="mb-0">Основная информация</h5> <div class="col-lg-7">
</div> <!-- Основная информация -->
<div class="card-body"> <div class="card mb-3">
<div class="row"> <div class="card-header">
<div class="col-md-6"> <h5 class="mb-0">Основная информация</h5>
<div class="mb-3">
<label for="{{ form.customer.id_for_label }}" class="form-label">
Клиент <span class="text-danger">*</span>
</label>
{% if preselected_customer %}
<select name="customer" class="form-select" id="id_customer">
<option value="{{ preselected_customer.pk }}" selected data-name="{{ preselected_customer.name }}" data-phone="{{ preselected_customer.phone|default:'' }}" data-email="{{ preselected_customer.email|default:'' }}">
{{ preselected_customer.name }}{% if preselected_customer.phone %} ({{ preselected_customer.phone }}){% endif %}
</option>
</select>
{% else %}
{{ form.customer }}
{% endif %}
{% if form.customer.errors %}
<div class="text-danger">{{ form.customer.errors }}</div>
{% endif %}
</div>
</div> </div>
<div class="col-md-6"> <div class="card-body">
<div class="mb-3"> <div class="row">
<label for="{{ form.status.id_for_label }}" class="form-label"> <div class="col-md-6">
Статус <span class="text-danger">*</span> <div class="mb-3">
</label> <label for="{{ form.customer.id_for_label }}" class="form-label">
{{ form.status }} Клиент <span class="text-danger">*</span>
{% if form.status.errors %} </label>
<div class="text-danger">{{ form.status.errors }}</div> {% if preselected_customer %}
{% endif %} <select name="customer" class="form-select" id="id_customer">
<option value="{{ preselected_customer.pk }}" selected data-name="{{ preselected_customer.name }}" data-phone="{{ preselected_customer.phone|default:'' }}" data-email="{{ preselected_customer.email|default:'' }}">
{{ preselected_customer.name }}{% if preselected_customer.phone %} ({{ preselected_customer.phone }}){% endif %}
</option>
</select>
{% else %}
{{ form.customer }}
{% endif %}
{% if form.customer.errors %}
<div class="text-danger">{{ form.customer.errors }}</div>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="{{ form.status.id_for_label }}" class="form-label">
Статус <span class="text-danger">*</span>
</label>
{{ form.status }}
{% if form.status.errors %}
<div class="text-danger">{{ form.status.errors }}</div>
{% endif %}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<!-- Дата и время доставки --> <!-- Дата и время доставки -->
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">Дата и время доставки</h5> <h5 class="mb-0">Дата и время доставки</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<div class="mb-3">
<label for="{{ form.delivery_date.id_for_label }}" class="form-label">Дата</label>
{{ form.delivery_date }}
{% if form.delivery_date.errors %}
<div class="text-danger">{{ form.delivery_date.errors }}</div>
{% endif %}
</div>
</div> </div>
<div class="col-md-4"> <div class="card-body">
<div class="mb-3"> <div class="row">
<label for="{{ form.delivery_time_start.id_for_label }}" class="form-label">Время от</label> <div class="col-md-4">
{{ form.delivery_time_start }} <div class="mb-3">
{% if form.delivery_time_start.errors %} <label for="{{ form.delivery_date.id_for_label }}" class="form-label">Дата</label>
<div class="text-danger">{{ form.delivery_time_start.errors }}</div> {{ form.delivery_date }}
{% endif %} {% if form.delivery_date.errors %}
</div> <div class="text-danger">{{ form.delivery_date.errors }}</div>
</div> {% endif %}
<div class="col-md-4"> </div>
<div class="mb-3"> </div>
<label for="{{ form.delivery_time_end.id_for_label }}" class="form-label">Время до</label> <div class="col-md-4">
{{ form.delivery_time_end }} <div class="mb-3">
{% if form.delivery_time_end.errors %} <label for="{{ form.delivery_time_start.id_for_label }}" class="form-label">Время от</label>
<div class="text-danger">{{ form.delivery_time_end.errors }}</div> {{ form.delivery_time_start }}
{% endif %} {% if form.delivery_time_start.errors %}
<div class="text-danger">{{ form.delivery_time_start.errors }}</div>
{% endif %}
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="{{ form.delivery_time_end.id_for_label }}" class="form-label">Время до</label>
{{ form.delivery_time_end }}
{% if form.delivery_time_end.errors %}
<div class="text-danger">{{ form.delivery_time_end.errors }}</div>
{% endif %}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<!-- Товары в заказе --> <!-- Товары в заказе -->
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">Товары в заказе</h5> <h5 class="mb-0">Товары в заказе</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
{{ formset.management_form }} {{ formset.management_form }}
<div id="order-items-container"> <div id="order-items-container">
{% for item_form in formset %} {% for item_form in formset %}
@@ -335,22 +339,22 @@
</div> </div>
</template> </template>
<button type="button" class="btn btn-success" id="add-item-btn"> <button type="button" class="btn btn-success" id="add-item-btn">
<i class="bi bi-plus-circle"></i> Добавить товар <i class="bi bi-plus-circle"></i> Добавить товар
</button> </button>
<button type="button" class="btn btn-primary ms-2" id="create-temp-kit-btn" data-bs-toggle="modal" data-bs-target="#tempKitModal"> <button type="button" class="btn btn-primary ms-2" id="create-temp-kit-btn" data-bs-toggle="modal" data-bs-target="#tempKitModal">
<i class="bi bi-flower1"></i> Создать и добавить комплект <i class="bi bi-flower1"></i> Создать и добавить комплект
</button> </button>
</div>
</div> </div>
</div>
<!-- Доставка --> <!-- Доставка -->
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">Доставка</h5> <h5 class="mb-0">Доставка</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<!-- Скрытое поле для передачи значения is_delivery --> <!-- Скрытое поле для передачи значения is_delivery -->
<div style="display: none;"> <div style="display: none;">
<input type="checkbox" <input type="checkbox"
id="{{ form.is_delivery.id_for_label }}" id="{{ form.is_delivery.id_for_label }}"
@@ -375,8 +379,8 @@
</div> </div>
</div> </div>
<!-- Поля доставки (показываются только если выбрана доставка) --> <!-- Поля доставки (показываются только если выбрана доставка) -->
<div id="delivery-mode-fields" style="display: block;"> <div id="delivery-mode-fields" style="display: block;">
<!-- Поля ввода адреса доставки --> <!-- Поля ввода адреса доставки -->
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
@@ -549,167 +553,223 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="row" id="pickup-fields" style="display: none;"> <!-- Поля самовывоза (показываются только если выбран самовывоз) -->
<div class="col-md-6"> <div class="row" id="pickup-fields" style="display: none;">
<div class="mb-3"> <div class="col-md-6">
<label for="{{ form.pickup_warehouse.id_for_label }}" class="form-label"> <div class="mb-3">
Склад для самовывоза <label for="{{ form.pickup_warehouse.id_for_label }}" class="form-label">
</label> Склад для самовывоза
{{ form.pickup_warehouse }} </label>
{% if form.pickup_warehouse.errors %} {{ form.pickup_warehouse }}
<div class="text-danger">{{ form.pickup_warehouse.errors }}</div> {% if form.pickup_warehouse.errors %}
{% endif %} <div class="text-danger">{{ form.pickup_warehouse.errors }}</div>
{% endif %}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Дополнительно -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-three-dots"></i> Дополнительно</h5>
</div>
<div class="card-body">
<div class="mb-3 form-check">
{{ form.is_anonymous }}
<label class="form-check-label" for="{{ form.is_anonymous.id_for_label }}">
Анонимная доставка
</label>
</div>
<div class="mb-3">
<label for="{{ form.special_instructions.id_for_label }}" class="form-label">Особые пожелания</label>
{{ form.special_instructions }}
</div>
</div>
</div>
</div> </div>
</div> <!-- Конец левой колонки -->
<!-- Правая колонка: Оплата -->
<!-- Оплата (смешанная оплата) --> <div class="col-lg-5">
<div class="card mb-3"> <!-- Оплата и возвраты -->
<div class="card-header"> <div class="card mb-3">
<h5 class="mb-0"><i class="bi bi-credit-card"></i> Оплата</h5> <div class="card-header">
</div> <div class="d-flex justify-content-between align-items-center">
<div class="card-body"> <h5 class="mb-0"><i class="bi bi-credit-card"></i> Оплата</h5>
{% if order.pk and order.amount_paid > 0 %}
<!-- Блок кошелька клиента --> <span class="badge bg-secondary">Доступно для возврата: {{ order.amount_paid|floatformat:2 }} руб.</span>
{% if order.customer %}
<div class="alert alert-info mb-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong><i class="bi bi-wallet2"></i> Кошелёк клиента:</strong>
{% if order.customer.wallet_balance > 0 %}
<span class="text-success fw-bold">{{ order.customer.wallet_balance|floatformat:2 }}&nbsp;руб.</span>
{% else %}
<span class="text-muted">0.00&nbsp;руб.</span>
{% endif %} {% endif %}
</div> </div>
<div>
<strong>Остаток к оплате:</strong>
<span class="text-primary fw-bold">{{ order.amount_due|floatformat:2 }}&nbsp;руб.</span>
</div>
</div> </div>
</div> <div class="card-body">
{% endif %}
<!-- Уже сохраненные платежи --> <!-- Блок статистики -->
{% if order.pk and order.payments.exists %} {% if order.pk %}
<div class="mb-4"> <div class="row mb-3">
<h6 class="text-muted mb-3"><i class="bi bi-check-circle"></i> Проведенные платежи</h6> <div class="col-md-6">
{% for payment in order.payments.all %} <div class="card border-primary">
<div class="border rounded p-3 mb-2 bg-light"> <div class="card-body text-center p-2">
<div class="row align-items-center"> <small class="text-muted d-block">Сумма заказа</small>
<div class="col-md-4"> <h6 class="mb-0 text-primary">{{ order.total_amount|floatformat:2 }} руб.</h6>
<small class="text-muted d-block">Способ оплаты</small> </div>
<strong>{{ payment.payment_method.name }}</strong> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-6">
<small class="text-muted d-block">Сумма</small> <div class="card border-success">
<strong class="text-success">{{ payment.amount|floatformat:2 }}&nbsp;руб.</strong> <div class="card-body text-center p-2">
</div> <small class="text-muted d-block">Оплачено</small>
<div class="col-md-4"> <h6 class="mb-0 text-success">{{ order.amount_paid|floatformat:2 }} руб.</h6>
<small class="text-muted d-block">Примечания</small> </div>
<span class="text-muted">{{ payment.notes|default:"—" }}</span> </div>
</div>
<div class="col-md-1 text-end">
<form method="post" action="{% url 'orders:payment-delete' order.order_number payment.id %}" style="display: inline;">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger btn-sm"
onclick="return confirm('Удалить платеж {{ payment.payment_method.name }} на сумму {{ payment.amount|floatformat:2 }} руб.?');"
title="Удалить платеж">
<i class="bi bi-trash"></i>
</button>
</form>
</div> </div>
</div> </div>
</div> <div class="row mb-3">
{% endfor %} <div class="col-md-6">
</div> <div class="card border-{% if order.amount_due > 0 %}warning{% else %}success{% endif %}">
{% endif %} <div class="card-body text-center p-2">
<small class="text-muted d-block">Остаток</small>
<!-- Итоговая сумма платежей --> <h6 class="mb-0 text-{% if order.amount_due > 0 %}warning{% else %}success{% endif %}">{{ order.amount_due|floatformat:2 }} руб.</h6>
{% if order.pk %} </div>
<div class="border-top pt-3 mt-3"> </div>
<div class="row align-items-center"> </div>
<div class="col"> {% if order.customer %}
<p class="mb-0 text-muted"><i class="bi bi-cash-stack"></i> Всего внесено:</p> <div class="col-md-6">
<div class="card border-info">
<div class="card-body text-center p-2">
<small class="text-muted d-block"><i class="bi bi-wallet2"></i> Кошелёк</small>
<h6 class="mb-0 text-info">{{ order.customer.wallet_balance|floatformat:2 }} руб.</h6>
</div>
</div>
</div>
{% endif %}
</div> </div>
<div class="col-auto"> {% endif %}
<h5 class="mb-0 text-success">
{{ order.amount_paid|default:"0.00"|floatformat:2 }}&nbsp;руб.
</h5>
</div>
</div>
</div>
{% endif %}
<!-- Форма добавления нового платежа (ОТДЕЛЬНАЯ форма внутри card-body) --> <!-- История транзакций -->
{% if order.pk %} {% if order.pk and order.transactions.exists %}
<div class="border-top pt-3 mt-3"> <div class="mb-3">
<h6 class="mb-3"><i class="bi bi-plus-circle"></i> Добавить новый платеж</h6> <h6 class="text-muted mb-2"><i class="bi bi-clock-history"></i> История транзакций</h6>
</div> <div class="table-responsive">
{% endif %} <table class="table table-sm table-hover mb-0">
</div> <thead class="table-light">
</div> <tr>
<th width="15%">Дата</th>
<!-- Форма добавления платежа (ВНЕ основной формы, но сразу после секции Оплата) --> <th width="10%">Тип</th>
{% if order.pk %} <th width="20%">Способ оплаты</th>
<div class="card mb-3"> <th width="12%" class="text-end">Сумма</th>
<div class="card-body"> <th width="30%">Примечания</th>
<form method="post" action="{% url 'orders:payment-add' order.order_number %}" id="payment-add-form"> <th width="8%">Кем</th>
{% csrf_token %} <th width="5%"></th>
<div class="row align-items-end"> </tr>
<div class="col-md-4"> </thead>
<label class="form-label">Способ оплаты</label> <tbody>
<select name="payment_method" class="form-select" required> {% for transaction in order.transactions.all|dictsortreversed:"transaction_date" %}
<option value="">---------</option> <tr>
{% load orders_tags %} <td>
{% get_payment_methods as payment_methods %} <small class="text-muted">
{% for pm in payment_methods %} {{ transaction.transaction_date|date:"d.m.Y H:i" }}
<option value="{{ pm.id }}">{{ pm.name }}</option> </small>
</td>
<td>
{% if transaction.transaction_type == 'payment' %}
<span class="badge bg-success">Платёж</span>
{% else %}
<span class="badge bg-warning text-dark">Возврат</span>
{% endif %}
</td>
<td>{{ transaction.payment_method.name }}</td>
<td class="text-end">
<strong class="{% if transaction.transaction_type == 'payment' %}text-success{% else %}text-danger{% endif %}">
{% if transaction.transaction_type == 'refund' %}{% else %}+{% endif %}{{ transaction.amount|floatformat:2 }}
</strong>
</td>
<td>
<small class="text-muted">
{{ transaction.notes|default:transaction.reason|default:"—"|truncatewords:8 }}
</small>
</td>
<td>
<small class="text-muted">
{% if transaction.created_by %}
{{ transaction.created_by.get_short_name|default:transaction.created_by.username }}
{% else %}
{% endif %}
</small>
</td>
<td class="text-end">
<form method="post" action="{% url 'orders:transaction-delete' order.order_number transaction.id %}" style="display: inline;">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger btn-sm"
onclick="return confirm('⚠️ Удаление нарушает историю транзакций!\n\nВместо удаления рекомендуется создать возврат.\n\nУдалить транзакцию {{ transaction.get_transaction_type_display }} на сумму {{ transaction.amount|floatformat:2 }} руб.?');"
title="Удалить транзакцию">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %} {% endfor %}
</select> </tbody>
</table>
</div>
</div> </div>
<div class="col-md-3"> {% elif order.pk %}
<label class="form-label">Сумма</label> <div class="alert alert-light mb-3">
<input type="number" name="amount" step="0.01" min="0.01" class="form-control" placeholder="0.00" required> <i class="bi bi-info-circle"></i> Транзакции по заказу отсутствуют
</div> </div>
<div class="col-md-3"> {% endif %}
<label class="form-label">Примечания</label>
<input type="text" name="notes" class="form-control" placeholder="Опционально"> </div>
</div> </div>
<div class="col-md-2">
<button type="submit" class="btn btn-success w-100"> <!-- Формы добавления платежа и возврата -->
<i class="bi bi-plus-lg"></i> Добавить {% if order.pk %}
</button> <div class="card mb-3">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="payment-tab" data-bs-toggle="tab" data-bs-target="#payment-form" type="button" role="tab">
<i class="bi bi-plus-circle"></i> Добавить платёж
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="refund-tab" data-bs-toggle="tab" data-bs-target="#refund-form" type="button" role="tab" {% if order.amount_paid <= 0 %}disabled{% endif %}>
<i class="bi bi-arrow-return-left"></i> Создать возврат
</button>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content">
<!-- Форма добавления платежа -->
<div class="tab-pane fade show active" id="payment-form" role="tabpanel">
<div class="row align-items-end">
<div class="col-12 mb-2">
<p class="text-muted"><i class="bi bi-info-circle"></i> Используйте форму ниже после сохранения заказа</p>
</div>
</div>
</div>
<!-- Форма создания возврата -->
<div class="tab-pane fade" id="refund-form" role="tabpanel">
<div class="alert alert-warning mb-3">
<i class="bi bi-exclamation-triangle"></i>
<strong>Возврат средств клиенту</strong><br>
<small>Используйте форму ниже после сохранения заказа</small>
</div>
</div>
</div> </div>
</div> </div>
</form>
</div>
</div>
{% endif %}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-three-dots"></i> Дополнительно</h5>
</div>
<div class="card-body">
<div class="mb-3 form-check">
{{ form.is_anonymous }}
<label class="form-check-label" for="{{ form.is_anonymous.id_for_label }}">
Анонимная доставка
</label>
</div>
<div class="mb-3">
<label for="{{ form.special_instructions.id_for_label }}" class="form-label">Особые пожелания</label>
{{ form.special_instructions }}
</div> </div>
{% endif %}
</div> </div>
<!-- Конец правой колонки -->
</div> </div>
<!-- Конец двух колонок -->
<!-- Кнопки --> <!-- Кнопки -->
<div class="row"> <div class="row">
@@ -736,6 +796,172 @@
</form> </form>
</div> </div>
<!-- Формы добавления платежа и возврата (ВЫНЕСЕНЫ ЗА ПРЕДЕЛЫ ОСНОВНОЙ ФОРМЫ) -->
{% if order.pk %}
<div class="container-fluid">
<div class="row">
<div class="col-lg-7">
<!-- Пустое место для выравнивания -->
</div>
<div class="col-lg-5">
<div class="card mb-3">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="payment-tab-real" data-bs-toggle="tab" data-bs-target="#payment-form-real" type="button" role="tab">
<i class="bi bi-plus-circle"></i> Добавить платёж
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="refund-tab-real" data-bs-toggle="tab" data-bs-target="#refund-form-real" type="button" role="tab" {% if order.amount_paid <= 0 %}disabled{% endif %}>
<i class="bi bi-arrow-return-left"></i> Создать возврат
</button>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content">
<!-- Форма добавления платежа -->
<div class="tab-pane fade show active" id="payment-form-real" role="tabpanel">
<form method="post" action="{% url 'orders:transaction-add-payment' order.order_number %}" id="payment-add-form">
{% csrf_token %}
<div class="row align-items-end">
<div class="col-12 mb-2">
<label class="form-label fw-bold">Способ оплаты <span class="text-danger">*</span></label>
<select name="payment_method" class="form-select" required>
<option value="">Выберите способ...</option>
{% load orders_tags %}
{% get_payment_methods as payment_methods %}
{% for pm in payment_methods %}
<option value="{{ pm.id }}">{{ pm.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6 mb-2">
<label class="form-label fw-bold">Сумма <span class="text-danger">*</span></label>
<div class="input-group">
<input type="number" name="amount" step="0.01" min="0.01"
class="form-control" placeholder="0.00"
{% if order.amount_due > 0 %}value="{{ order.amount_due|unlocalize }}"{% endif %} required>
<span class="input-group-text">руб.</span>
</div>
</div>
<div class="col-md-6 mb-2">
<label class="form-label fw-bold">Остаток</label>
<div class="form-control-plaintext fw-bold text-warning">
{{ order.amount_due|floatformat:2 }} руб.
</div>
</div>
<div class="col-12 mb-2">
<label class="form-label fw-bold">Примечания</label>
<input type="text" name="notes" class="form-control" placeholder="Опционально">
</div>
<div class="col-12">
<button type="submit" class="btn btn-success w-100">
<i class="bi bi-check-lg"></i> Добавить платёж
</button>
</div>
</div>
</form>
</div>
<!-- Форма создания возврата -->
<div class="tab-pane fade" id="refund-form-real" role="tabpanel">
<form method="post" action="{% url 'orders:transaction-add-refund' order.order_number %}" id="refund-add-form">
{% csrf_token %}
<div class="alert alert-warning mb-3">
<i class="bi bi-exclamation-triangle"></i>
<strong>Возврат средств клиенту</strong><br>
<small>Можно вернуть любую сумму до {{ order.amount_paid|floatformat:2 }} руб. Возврат на кошелёк произойдёт автоматически при выборе соответствующего способа оплаты.</small>
</div>
<div class="row align-items-end">
<div class="col-12 mb-2">
<label class="form-label fw-bold">Способ возврата <span class="text-danger">*</span></label>
<select name="refund_payment_method" class="form-select" required>
<option value="">Выберите способ...</option>
{% load orders_tags %}
{% get_payment_methods as payment_methods %}
{% for pm in payment_methods %}
<option value="{{ pm.id }}">{{ pm.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 mb-2">
<label class="form-label fw-bold">Сумма возврата <span class="text-danger">*</span></label>
<div class="input-group">
<input type="number" name="refund_amount" step="0.01" min="0.01"
max="{{ order.amount_paid|unlocalize }}" class="form-control"
placeholder="0.00" required>
<span class="input-group-text">руб.</span>
</div>
<small class="text-muted">Макс: {{ order.amount_paid|floatformat:2 }}</small>
</div>
<div class="col-12 mb-2">
<label class="form-label fw-bold">Причина <span class="text-danger">*</span></label>
<input type="text" name="refund_reason" class="form-control"
placeholder="Укажите причину возврата" required>
</div>
<div class="col-12 mb-2">
<label class="form-label fw-bold">Примечания</label>
<input type="text" name="refund_notes" class="form-control" placeholder="Опционально">
</div>
<div class="col-12">
<button type="submit" class="btn btn-warning w-100 text-dark">
<i class="bi bi-arrow-return-left"></i> Вернуть средства
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<script>
// Добавляем логирование для кнопки сохранения
document.addEventListener('DOMContentLoaded', function() {
const orderForm = document.getElementById('order-form');
const submitButtons = orderForm.querySelectorAll('button[type="submit"]');
console.log('[FORM DEBUG] Форма найдена:', orderForm);
console.log('[FORM DEBUG] Кнопки submit найдены:', submitButtons.length);
submitButtons.forEach((btn, index) => {
console.log('[FORM DEBUG] Кнопка', index, ':', btn);
console.log('[FORM DEBUG] Кнопка disabled?', btn.disabled);
console.log('[FORM DEBUG] Кнопка type:', btn.type);
console.log('[FORM DEBUG] Текст кнопки:', btn.textContent.trim());
// Проверяем disabled при каждом клике
btn.addEventListener('click', function(e) {
console.log('[FORM DEBUG] Клик по кнопке!');
console.log('[FORM DEBUG] Event:', e);
console.log('[FORM DEBUG] Кнопка disabled на момент клика?', this.disabled);
console.log('[FORM DEBUG] DefaultPrevented?', e.defaultPrevented);
console.log('[FORM DEBUG] Форма валидна?', orderForm.checkValidity());
// Проверяем, не блокирует ли что-то отправку
if (this.disabled) {
console.error('[FORM DEBUG] ОШИБКА: Кнопка заблокирована!');
e.preventDefault();
return false;
}
}, true); // Используем capturing phase
});
// Слушаем событие submit формы
orderForm.addEventListener('submit', function(e) {
console.log('[FORM DEBUG] Событие submit формы!');
console.log('[FORM DEBUG] Event:', e);
console.log('[FORM DEBUG] DefaultPrevented?', e.defaultPrevented);
}, true);
});
</script>
<script> <script>
// Глобально определяем initOrderItemSelect2 // Глобально определяем initOrderItemSelect2
window.initOrderItemSelect2 = function(element) { window.initOrderItemSelect2 = function(element) {

View File

@@ -11,9 +11,10 @@ urlpatterns = [
path('<int:order_number>/edit/', views.order_update, name='order-update'), path('<int:order_number>/edit/', views.order_update, name='order-update'),
path('<int:order_number>/delete/', views.order_delete, name='order-delete'), path('<int:order_number>/delete/', views.order_delete, name='order-delete'),
# Payment Management # Transaction Management
path('<int:order_number>/payments/add/', views.payment_add, name='payment-add'), path('<int:order_number>/transactions/payment/add/', views.transaction_add_payment, name='transaction-add-payment'),
path('<int:order_number>/payments/<int:payment_id>/delete/', views.payment_delete, name='payment-delete'), path('<int:order_number>/transactions/refund/add/', views.transaction_add_refund, name='transaction-add-refund'),
path('<int:order_number>/transactions/<int:transaction_id>/delete/', views.transaction_delete, name='transaction-delete'),
# AJAX endpoints # AJAX endpoints
path('api/customer-address-history/', views.get_customer_address_history, name='api-customer-address-history'), path('api/customer-address-history/', views.get_customer_address_history, name='api-customer-address-history'),

View File

@@ -8,8 +8,8 @@ from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from decimal import Decimal from decimal import Decimal
from .models import Order, OrderItem, Address, OrderStatus, Payment, PaymentMethod from .models import Order, OrderItem, Address, OrderStatus, Transaction, PaymentMethod
from .forms import OrderForm, OrderItemFormSet, OrderStatusForm, PaymentForm from .forms import OrderForm, OrderItemFormSet, OrderStatusForm, TransactionForm
from .filters import OrderFilter from .filters import OrderFilter
from .services.address_service import AddressService from .services.address_service import AddressService
import json import json
@@ -49,7 +49,7 @@ def order_detail(request, order_number):
"""Детальная информация о заказе""" """Детальная информация о заказе"""
order = get_object_or_404( order = get_object_or_404(
Order.objects.select_related('customer', 'delivery_address', 'pickup_warehouse', 'modified_by') Order.objects.select_related('customer', 'delivery_address', 'pickup_warehouse', 'modified_by')
.prefetch_related('items__product', 'items__product_kit', 'payments__created_by'), .prefetch_related('items__product', 'items__product_kit', 'transactions__created_by'),
order_number=order_number order_number=order_number
) )
@@ -133,6 +133,9 @@ def order_create(request):
def order_update(request, order_number): def order_update(request, order_number):
"""Редактирование заказа""" """Редактирование заказа"""
order = get_object_or_404(Order, order_number=order_number) order = get_object_or_404(Order, order_number=order_number)
# Пересчитываем amount_paid на основе транзакций (на случай миграции)
order.recalculate_amount_paid()
if request.method == 'POST': if request.method == 'POST':
form = OrderForm(request.POST, instance=order) form = OrderForm(request.POST, instance=order)
@@ -221,37 +224,37 @@ def order_delete(request, order_number):
return render(request, 'orders/order_confirm_delete.html', context) return render(request, 'orders/order_confirm_delete.html', context)
# === УПРАВЛЕНИЕ ПЛАТЕЖАМИ === # === УПРАВЛЕНИЕ ТРАНЗАКЦИЯМИ ===
@login_required @login_required
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def payment_add(request, order_number): def transaction_add_payment(request, order_number):
""" """
Добавление нового платежа к заказу. Добавление нового платежа к заказу.
Отдельный endpoint для чистоты архитектуры.
""" """
from orders.services.transaction_service import TransactionService
order = get_object_or_404(Order, order_number=order_number) order = get_object_or_404(Order, order_number=order_number)
form = PaymentForm(request.POST) form = TransactionForm(request.POST)
if form.is_valid(): if form.is_valid():
payment = form.save(commit=False)
payment.order = order
payment.created_by = request.user
try: try:
# save() вызовет Payment.save() который обработает: # Создаём транзакцию платежа
# - Списание из кошелька (если account_balance) transaction = TransactionService.create_payment(
# - Обработку переплаты order=order,
# - Обновление amount_paid и payment_status amount=form.cleaned_data['amount'],
payment.save() payment_method=form.cleaned_data['payment_method'],
user=request.user,
notes=form.cleaned_data.get('notes')
)
messages.success( messages.success(
request, request,
f'Платеж на сумму {payment.amount} руб. ' f'Платёж на сумму {transaction.amount} руб. '
f'({payment.payment_method.name}) успешно добавлен.' f'({transaction.payment_method.name}) успешно добавлен.'
) )
except ValidationError as e: except (ValidationError, ValueError) as e:
messages.error(request, f'Ошибка при добавлении платежа: {e}') messages.error(request, f'Ошибка при добавлении платежа: {e}')
else: else:
# Показываем ошибки валидации # Показываем ошибки валидации
@@ -265,29 +268,68 @@ def payment_add(request, order_number):
@login_required @login_required
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def payment_delete(request, order_number, payment_id): def transaction_add_refund(request, order_number):
""" """
Удаление платежа. Добавление возврата по заказу.
Возвращает средства в кошелек, если платеж был из кошелька. """
from orders.services.transaction_service import TransactionService
order = get_object_or_404(Order, order_number=order_number)
amount = request.POST.get('refund_amount')
payment_method_id = request.POST.get('refund_payment_method')
reason = request.POST.get('refund_reason')
notes = request.POST.get('refund_notes')
try:
amount = Decimal(amount)
payment_method = get_object_or_404(PaymentMethod, pk=payment_method_id)
# Создаём транзакцию возврата
transaction = TransactionService.create_refund(
order=order,
amount=amount,
payment_method=payment_method,
user=request.user,
reason=reason,
notes=notes
)
messages.success(
request,
f'Возврат на сумму {transaction.amount} руб. '
f'({transaction.payment_method.name}) успешно создан.'
)
except (ValidationError, ValueError) as e:
messages.error(request, f'Ошибка при создании возврата: {e}')
return redirect('orders:order-update', order_number=order.order_number)
@login_required
@require_http_methods(["POST"])
def transaction_delete(request, order_number, transaction_id):
"""
Удаление транзакции (не рекомендуется, лучше использовать refund).
Оставлено для совместимости.
""" """
order = get_object_or_404(Order, order_number=order_number) order = get_object_or_404(Order, order_number=order_number)
payment = get_object_or_404(Payment, pk=payment_id, order=order) transaction_obj = get_object_or_404(Transaction, pk=transaction_id, order=order)
# Сохраняем данные для сообщения # Сохраняем данные для сообщения
payment_info = f'{payment.payment_method.name} на сумму {payment.amount} руб.' transaction_info = f'{transaction_obj.get_transaction_type_display()} {transaction_obj.payment_method.name} на сумму {transaction_obj.amount} руб.'
# Если это платеж из кошелька - возвращаем средства # Предупреждение: удаление транзакций нарушает историю
if payment.payment_method.code == 'account_balance': transaction_obj.delete()
from customers.services.wallet_service import WalletService
WalletService.refund_wallet_payment(order, payment.amount, request.user)
payment.delete() # Пересчитываем баланс
order.recalculate_amount_paid()
# Пересчитываем сумму оплаты messages.warning(
order.amount_paid = sum(p.amount for p in order.payments.all()) request,
order.update_payment_status() f'Транзакция {transaction_info} удалена. '
f'Рекомендуем использовать "Возврат" вместо удаления.'
messages.success(request, f'Платеж {payment_info} успешно удален.') )
return redirect('orders:order-update', order_number=order.order_number) return redirect('orders:order-update', order_number=order.order_number)