Исправлена форма заказа: две колонки и корректная работа кнопки сохранения
- Разделен экран на две колонки: заказ слева, оплата справа - Форма оплаты вынесена за пределы основной формы заказа (устранена проблема вложенных форм) - Исправлен метод calculate_total() для сохранения итоговой суммы в БД - Добавлена модель Transaction для учета платежей и возвратов - Добавлена модель PaymentMethod для методов оплаты - Удалена старая модель Payment, заменена на Transaction - Добавлен TransactionService для управления транзакциями - Обновлен интерфейс форм оплаты для правой колонки - Кнопка 'Сохранить изменения' теперь работает корректно
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
|
||||||
230
myproject/orders/services/transaction_service.py
Normal file
230
myproject/orders/services/transaction_service.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"""
|
||||||
|
Сервис для работы с финансовыми транзакциями заказа.
|
||||||
|
Обрабатывает платежи и возвраты.
|
||||||
|
"""
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from django.db import transaction
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
# Константа для округления до 2 знаков
|
||||||
|
QUANTIZE_2D = Decimal('0.01')
|
||||||
|
|
||||||
|
|
||||||
|
def _quantize(value):
|
||||||
|
"""Округление до 2 знаков после запятой"""
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
value = Decimal(str(value))
|
||||||
|
return value.quantize(QUANTIZE_2D, rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionService:
|
||||||
|
"""
|
||||||
|
Сервис для управления финансовыми транзакциями заказа.
|
||||||
|
Все операции атомарны.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def create_payment(order, amount, payment_method, user, notes=None):
|
||||||
|
"""
|
||||||
|
Создать платёж по заказу.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
order: Заказ для оплаты
|
||||||
|
amount: Сумма платежа
|
||||||
|
payment_method: Способ оплаты (PaymentMethod или code)
|
||||||
|
user: Пользователь, создающий платёж
|
||||||
|
notes: Примечания
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transaction: Созданная транзакция платежа
|
||||||
|
"""
|
||||||
|
from orders.models import Transaction, PaymentMethod
|
||||||
|
|
||||||
|
# Округляем сумму
|
||||||
|
amount = _quantize(amount)
|
||||||
|
if amount <= 0:
|
||||||
|
raise ValueError('Сумма платежа должна быть положительной')
|
||||||
|
|
||||||
|
# Получаем PaymentMethod если передан code
|
||||||
|
if isinstance(payment_method, str):
|
||||||
|
try:
|
||||||
|
payment_method = PaymentMethod.objects.get(code=payment_method)
|
||||||
|
except PaymentMethod.DoesNotExist:
|
||||||
|
raise ValueError(f'Способ оплаты "{payment_method}" не найден')
|
||||||
|
|
||||||
|
# Создаём транзакцию
|
||||||
|
txn = Transaction.objects.create(
|
||||||
|
order=order,
|
||||||
|
transaction_type='payment',
|
||||||
|
amount=amount,
|
||||||
|
payment_method=payment_method,
|
||||||
|
created_by=user,
|
||||||
|
notes=notes or f'Платёж {payment_method.name}'
|
||||||
|
)
|
||||||
|
|
||||||
|
return txn
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def create_refund(order, amount, payment_method, user, reason=None, notes=None, related_payment=None):
|
||||||
|
"""
|
||||||
|
Создать возврат по заказу.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
order: Заказ для возврата
|
||||||
|
amount: Сумма возврата
|
||||||
|
payment_method: Способ возврата (PaymentMethod или code)
|
||||||
|
user: Пользователь, создающий возврат
|
||||||
|
reason: Причина возврата
|
||||||
|
notes: Примечания
|
||||||
|
related_payment: Связанный платёж (Transaction ID или объект)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transaction: Созданная транзакция возврата
|
||||||
|
"""
|
||||||
|
from orders.models import Transaction, PaymentMethod
|
||||||
|
|
||||||
|
# Округляем сумму
|
||||||
|
amount = _quantize(amount)
|
||||||
|
if amount <= 0:
|
||||||
|
raise ValueError('Сумма возврата должна быть положительной')
|
||||||
|
|
||||||
|
# Проверяем, что не возвращаем больше чем оплачено
|
||||||
|
if amount > order.amount_paid:
|
||||||
|
raise ValidationError(
|
||||||
|
f'Сумма возврата ({amount}) превышает оплаченную сумму ({order.amount_paid})'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Получаем PaymentMethod если передан code
|
||||||
|
if isinstance(payment_method, str):
|
||||||
|
try:
|
||||||
|
payment_method = PaymentMethod.objects.get(code=payment_method)
|
||||||
|
except PaymentMethod.DoesNotExist:
|
||||||
|
raise ValueError(f'Способ оплаты "{payment_method}" не найден')
|
||||||
|
|
||||||
|
# Получаем related_payment если передан ID
|
||||||
|
if isinstance(related_payment, int):
|
||||||
|
try:
|
||||||
|
related_payment = Transaction.objects.get(pk=related_payment, transaction_type='payment')
|
||||||
|
except Transaction.DoesNotExist:
|
||||||
|
raise ValueError(f'Платёж #{related_payment} не найден')
|
||||||
|
|
||||||
|
# Создаём транзакцию возврата
|
||||||
|
txn = Transaction.objects.create(
|
||||||
|
order=order,
|
||||||
|
transaction_type='refund',
|
||||||
|
amount=amount,
|
||||||
|
payment_method=payment_method,
|
||||||
|
related_payment=related_payment,
|
||||||
|
reason=reason or 'Возврат средств',
|
||||||
|
notes=notes,
|
||||||
|
created_by=user
|
||||||
|
)
|
||||||
|
|
||||||
|
return txn
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@transaction.atomic
|
||||||
|
def create_full_refund(order, user, reason=None):
|
||||||
|
"""
|
||||||
|
Полный возврат всей оплаченной суммы заказа.
|
||||||
|
Возвращает средства пропорционально платежам (LIFO - последний вошёл, первый вышел).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
order: Заказ для возврата
|
||||||
|
user: Пользователь, создающий возврат
|
||||||
|
reason: Причина возврата
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[Transaction]: Список созданных транзакций возврата
|
||||||
|
"""
|
||||||
|
from orders.models import Transaction
|
||||||
|
|
||||||
|
if order.amount_paid <= 0:
|
||||||
|
raise ValidationError('Нет средств для возврата')
|
||||||
|
|
||||||
|
refunds = []
|
||||||
|
remaining_to_refund = order.amount_paid
|
||||||
|
|
||||||
|
# Берём платежи в обратном порядке (LIFO)
|
||||||
|
payments = order.transactions.filter(transaction_type='payment').order_by('-transaction_date')
|
||||||
|
|
||||||
|
for payment in payments:
|
||||||
|
if remaining_to_refund <= 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Сколько уже вернули по этому платежу
|
||||||
|
from django.db.models import Sum
|
||||||
|
already_refunded = payment.refunds.aggregate(
|
||||||
|
total=Sum('amount')
|
||||||
|
)['total'] or Decimal('0')
|
||||||
|
|
||||||
|
# Сколько можно вернуть по этому платежу
|
||||||
|
available_for_refund = payment.amount - already_refunded
|
||||||
|
|
||||||
|
if available_for_refund <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Возвращаем минимум из доступного и оставшегося
|
||||||
|
refund_amount = min(available_for_refund, remaining_to_refund)
|
||||||
|
|
||||||
|
# Создаём возврат
|
||||||
|
refund = TransactionService.create_refund(
|
||||||
|
order=order,
|
||||||
|
amount=refund_amount,
|
||||||
|
payment_method=payment.payment_method,
|
||||||
|
user=user,
|
||||||
|
reason=reason or 'Полный возврат заказа',
|
||||||
|
related_payment=payment
|
||||||
|
)
|
||||||
|
|
||||||
|
refunds.append(refund)
|
||||||
|
remaining_to_refund -= refund_amount
|
||||||
|
|
||||||
|
return refunds
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_payment_summary(order):
|
||||||
|
"""
|
||||||
|
Получить сводку по платежам и возвратам заказа.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
order: Заказ
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Сводка с суммами платежей, возвратов и балансом
|
||||||
|
"""
|
||||||
|
from django.db.models import Sum
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
payments_sum = order.transactions.filter(
|
||||||
|
transaction_type='payment'
|
||||||
|
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
|
||||||
|
|
||||||
|
refunds_sum = order.transactions.filter(
|
||||||
|
transaction_type='refund'
|
||||||
|
).aggregate(total=Sum('amount'))['total'] or Decimal('0')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'payments_total': payments_sum,
|
||||||
|
'refunds_total': refunds_sum,
|
||||||
|
'amount_paid': payments_sum - refunds_sum,
|
||||||
|
'amount_due': max(order.total_amount - (payments_sum - refunds_sum), Decimal('0')),
|
||||||
|
'payments_count': order.transactions.filter(transaction_type='payment').count(),
|
||||||
|
'refunds_count': order.transactions.filter(transaction_type='refund').count(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_refundable_amount(order):
|
||||||
|
"""
|
||||||
|
Получить сумму, доступную для возврата.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
order: Заказ
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decimal: Сумма, которую можно вернуть
|
||||||
|
"""
|
||||||
|
return max(order.amount_paid, Decimal('0'))
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }} руб.</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">0.00 руб.</span>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<strong>Остаток к оплате:</strong>
|
|
||||||
<span class="text-primary fw-bold">{{ order.amount_due|floatformat:2 }} руб.</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 }} руб.</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 }} руб.
|
|
||||||
</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) {
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,6 +134,9 @@ 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)
|
||||||
formset = OrderItemFormSet(request.POST, instance=order)
|
formset = OrderItemFormSet(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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user