Рефакторинг моделей заказов и добавление методов оплаты
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# -*- 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, Address, OrderStatus
|
from .models import Order, OrderItem, Payment, PaymentMethod, Address, OrderStatus
|
||||||
|
|
||||||
|
|
||||||
class PaymentInline(admin.TabularInline):
|
class PaymentInline(admin.TabularInline):
|
||||||
@@ -94,7 +94,6 @@ class OrderAdmin(admin.ModelAdmin):
|
|||||||
}),
|
}),
|
||||||
('Оплата', {
|
('Оплата', {
|
||||||
'fields': (
|
'fields': (
|
||||||
'payment_method',
|
|
||||||
'total_amount',
|
'total_amount',
|
||||||
'discount_amount',
|
'discount_amount',
|
||||||
'amount_paid',
|
'amount_paid',
|
||||||
@@ -376,3 +375,78 @@ class OrderStatusAdmin(admin.ModelAdmin):
|
|||||||
if obj.is_system or obj.orders_count > 0:
|
if obj.is_system or obj.orders_count > 0:
|
||||||
return False
|
return False
|
||||||
return super().has_delete_permission(request, obj)
|
return super().has_delete_permission(request, obj)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PaymentMethod)
|
||||||
|
class PaymentMethodAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Админ-панель для управления способами оплаты.
|
||||||
|
"""
|
||||||
|
list_display = [
|
||||||
|
'order_display',
|
||||||
|
'name',
|
||||||
|
'code',
|
||||||
|
'description',
|
||||||
|
'is_active',
|
||||||
|
'is_system',
|
||||||
|
'payments_count',
|
||||||
|
]
|
||||||
|
|
||||||
|
list_filter = [
|
||||||
|
'is_active',
|
||||||
|
'is_system',
|
||||||
|
]
|
||||||
|
|
||||||
|
search_fields = [
|
||||||
|
'name',
|
||||||
|
'code',
|
||||||
|
'description',
|
||||||
|
]
|
||||||
|
|
||||||
|
readonly_fields = ['created_at', 'updated_at', 'created_by']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Основная информация', {
|
||||||
|
'fields': ('code', 'name', 'description', 'order')
|
||||||
|
}),
|
||||||
|
('Настройки', {
|
||||||
|
'fields': ('is_active', 'is_system')
|
||||||
|
}),
|
||||||
|
('Системная информация', {
|
||||||
|
'fields': ('created_at', 'updated_at', 'created_by'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
ordering = ['order', 'name']
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
"""Делаем код readonly для системных способов оплаты"""
|
||||||
|
readonly = list(self.readonly_fields)
|
||||||
|
if obj and obj.is_system:
|
||||||
|
readonly.append('code')
|
||||||
|
return readonly
|
||||||
|
|
||||||
|
def order_display(self, obj):
|
||||||
|
"""Отображение порядкового номера с бейджем"""
|
||||||
|
return format_html(
|
||||||
|
'<span style="display: inline-block; background-color: #6c757d; color: white; padding: 2px 8px; border-radius: 10px; font-size: 11px;">{}</span>',
|
||||||
|
obj.order
|
||||||
|
)
|
||||||
|
order_display.short_description = 'Порядок'
|
||||||
|
|
||||||
|
def payments_count(self, obj):
|
||||||
|
"""Количество платежей этим способом"""
|
||||||
|
count = obj.payments.count()
|
||||||
|
if count == 0:
|
||||||
|
return format_html('<span style="color: #999;">{}</span>', count)
|
||||||
|
return format_html('<span style="font-weight: bold;">{}</span>', count)
|
||||||
|
payments_count.short_description = 'Платежей'
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
"""Запрещаем удаление используемых способов оплаты"""
|
||||||
|
if obj:
|
||||||
|
# Разрешаем удаление только если нет связанных платежей
|
||||||
|
if obj.payments.exists():
|
||||||
|
return False
|
||||||
|
return super().has_delete_permission(request, obj)
|
||||||
|
|||||||
@@ -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, Address, OrderStatus
|
from .models import Order, OrderItem, Payment, 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
|
||||||
@@ -101,7 +101,6 @@ class OrderForm(forms.ModelForm):
|
|||||||
'recipient_name',
|
'recipient_name',
|
||||||
'recipient_phone',
|
'recipient_phone',
|
||||||
'status',
|
'status',
|
||||||
'payment_method',
|
|
||||||
'discount_amount',
|
'discount_amount',
|
||||||
'is_anonymous',
|
'is_anonymous',
|
||||||
'special_instructions',
|
'special_instructions',
|
||||||
@@ -443,3 +442,53 @@ TemporaryKitItemFormSet = formset_factory(
|
|||||||
min_num=1, # Минимум 1 компонент в комплекте
|
min_num=1, # Минимум 1 компонент в комплекте
|
||||||
validate_min=True,
|
validate_min=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# === ПЛАТЕЖИ (СМЕШАННАЯ ОПЛАТА) ===
|
||||||
|
|
||||||
|
class PaymentForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Форма для создания платежа по заказу.
|
||||||
|
Поддерживает смешанную оплату (несколько платежей на один заказ).
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
model = Payment
|
||||||
|
fields = ['payment_method', 'amount', 'notes']
|
||||||
|
widgets = {
|
||||||
|
'payment_method': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'amount': forms.NumberInput(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'step': '0.01',
|
||||||
|
'min': '0',
|
||||||
|
'placeholder': 'Сумма платежа'
|
||||||
|
}),
|
||||||
|
'notes': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control',
|
||||||
|
'rows': 2,
|
||||||
|
'placeholder': 'Примечания к платежу (опционально)'
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
# Фильтруем только активные способы оплаты
|
||||||
|
from .models import PaymentMethod
|
||||||
|
self.fields['payment_method'].queryset = PaymentMethod.objects.filter(
|
||||||
|
is_active=True
|
||||||
|
).order_by('order', 'name')
|
||||||
|
|
||||||
|
# Делаем notes опциональным
|
||||||
|
self.fields['notes'].required = False
|
||||||
|
|
||||||
|
|
||||||
|
# Formset для множественных платежей
|
||||||
|
PaymentFormSet = inlineformset_factory(
|
||||||
|
Order,
|
||||||
|
Payment,
|
||||||
|
form=PaymentForm,
|
||||||
|
extra=0, # Без пустых форм (добавляем через JavaScript)
|
||||||
|
can_delete=True,
|
||||||
|
min_num=0, # Платежи не обязательны при создании черновика
|
||||||
|
validate_min=False,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
# Management commands for orders app
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
# Management commands
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from orders.models import PaymentMethod
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Создаёт стандартные способы оплаты для цветочного магазина'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
payment_methods = [
|
||||||
|
{
|
||||||
|
'code': 'cash',
|
||||||
|
'name': 'Наличными',
|
||||||
|
'description': 'Оплата наличными деньгами',
|
||||||
|
'is_system': True,
|
||||||
|
'order': 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'card',
|
||||||
|
'name': 'Картой',
|
||||||
|
'description': 'Оплата банковской картой',
|
||||||
|
'is_system': True,
|
||||||
|
'order': 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'online',
|
||||||
|
'name': 'Онлайн',
|
||||||
|
'description': 'Онлайн оплата через платежную систему',
|
||||||
|
'is_system': True,
|
||||||
|
'order': 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'legal_entity',
|
||||||
|
'name': 'Безнал от ЮРЛИЦ',
|
||||||
|
'description': 'Безналичный расчёт от юридических лиц',
|
||||||
|
'is_system': True,
|
||||||
|
'order': 4
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
for method_data in payment_methods:
|
||||||
|
method, created = PaymentMethod.objects.get_or_create(
|
||||||
|
code=method_data['code'],
|
||||||
|
defaults=method_data
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
created_count += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f'✓ Создан способ оплаты: {method.name}')
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f'• Уже существует: {method.name}')
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f'\nГотово! Создано {created_count} новых способов оплаты.')
|
||||||
|
)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Generated by Django 5.0.10 on 2025-11-26 08:06
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('orders', '0003_historicalorderitem_is_from_showcase_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='historicalorder',
|
||||||
|
name='payment_method',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='order',
|
||||||
|
name='payment_method',
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PaymentMethod',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('code', models.SlugField(help_text="Уникальный идентификатор (например: 'cash_to_courier', 'card_to_courier')", unique=True, verbose_name='Код способа оплаты')),
|
||||||
|
('name', models.CharField(max_length=100, verbose_name='Название способа оплаты')),
|
||||||
|
('description', models.TextField(blank=True, help_text='Дополнительная информация о способе оплаты', verbose_name='Описание')),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='Отключенные способы оплаты не отображаются при создании заказа', verbose_name='Активен')),
|
||||||
|
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок отображения')),
|
||||||
|
('is_system', models.BooleanField(default=False, help_text='Системные способы оплаты нельзя удалить через интерфейс', verbose_name='Системный')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_payment_methods', to=settings.AUTH_USER_MODEL, verbose_name='Создано')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Способ оплаты',
|
||||||
|
'verbose_name_plural': 'Способы оплаты',
|
||||||
|
'ordering': ['order', 'name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='payment_method',
|
||||||
|
field=models.ForeignKey(help_text='Способ оплаты данного платежа', on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='orders.paymentmethod', verbose_name='Способ оплаты'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='paymentmethod',
|
||||||
|
index=models.Index(fields=['code'], name='orders_paym_code_f40d7e_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='paymentmethod',
|
||||||
|
index=models.Index(fields=['is_active'], name='orders_paym_is_acti_e2be69_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='paymentmethod',
|
||||||
|
index=models.Index(fields=['order'], name='orders_paym_order_94e282_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,848 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from accounts.models import CustomUser
|
|
||||||
from customers.models import Customer
|
|
||||||
from products.models import Product, ProductKit
|
|
||||||
from inventory.models import Warehouse
|
|
||||||
from simple_history.models import HistoricalRecords
|
|
||||||
|
|
||||||
|
|
||||||
class OrderStatus(models.Model):
|
|
||||||
"""
|
|
||||||
Статус заказа, управляется отдельно для каждого тенанта.
|
|
||||||
Благодаря django-tenants в TENANT_APPS, данные изолированы по схемам.
|
|
||||||
"""
|
|
||||||
name = models.CharField(
|
|
||||||
max_length=100,
|
|
||||||
verbose_name="Название статуса"
|
|
||||||
)
|
|
||||||
|
|
||||||
code = models.SlugField(
|
|
||||||
unique=True,
|
|
||||||
verbose_name="Код статуса",
|
|
||||||
help_text="Уникальный идентификатор (например: 'completed', 'cancelled')"
|
|
||||||
)
|
|
||||||
|
|
||||||
label = models.CharField(
|
|
||||||
max_length=100,
|
|
||||||
verbose_name="Метка для отображения",
|
|
||||||
blank=True
|
|
||||||
)
|
|
||||||
|
|
||||||
is_system = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name="Системный статус",
|
|
||||||
help_text="True для встроенных статусов (draft, completed, cancelled)"
|
|
||||||
)
|
|
||||||
|
|
||||||
is_positive_end = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name="Положительный исход сделки",
|
|
||||||
help_text="True если это финальный успешный статус (Выполнен)"
|
|
||||||
)
|
|
||||||
|
|
||||||
is_negative_end = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name="Отрицательный исход сделки",
|
|
||||||
help_text="True если это финальный отрицательный статус (Отменен)"
|
|
||||||
)
|
|
||||||
|
|
||||||
order = models.PositiveIntegerField(
|
|
||||||
default=0,
|
|
||||||
verbose_name="Порядок отображения"
|
|
||||||
)
|
|
||||||
|
|
||||||
color = models.CharField(
|
|
||||||
max_length=7,
|
|
||||||
blank=True,
|
|
||||||
default='#808080',
|
|
||||||
verbose_name="Цвет (hex)",
|
|
||||||
help_text="Например: #FF5733"
|
|
||||||
)
|
|
||||||
|
|
||||||
description = models.TextField(
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Описание"
|
|
||||||
)
|
|
||||||
|
|
||||||
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_order_statuses',
|
|
||||||
verbose_name="Создано"
|
|
||||||
)
|
|
||||||
|
|
||||||
updated_by = models.ForeignKey(
|
|
||||||
CustomUser,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='updated_order_statuses',
|
|
||||||
verbose_name="Последнее изменение"
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Статус заказа"
|
|
||||||
verbose_name_plural = "Статусы заказов"
|
|
||||||
ordering = ['order', 'name']
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['code']),
|
|
||||||
models.Index(fields=['is_system']),
|
|
||||||
models.Index(fields=['order']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def orders_count(self):
|
|
||||||
"""Количество заказов в этом статусе"""
|
|
||||||
return self.orders.count()
|
|
||||||
|
|
||||||
|
|
||||||
class Address(models.Model):
|
|
||||||
"""
|
|
||||||
Модель адреса доставки для заказа цветочного магазина в Минске.
|
|
||||||
Адрес принадлежит конкретному заказу доставки.
|
|
||||||
"""
|
|
||||||
# Информация о получателе
|
|
||||||
recipient_name = models.CharField(
|
|
||||||
max_length=200,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Имя получателя",
|
|
||||||
help_text="Имя человека, которому будет доставлен заказ"
|
|
||||||
)
|
|
||||||
|
|
||||||
recipient_phone = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Телефон получателя",
|
|
||||||
help_text="Контактный телефон получателя для уточнения адреса"
|
|
||||||
)
|
|
||||||
|
|
||||||
street = models.CharField(
|
|
||||||
max_length=255,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Улица"
|
|
||||||
)
|
|
||||||
|
|
||||||
building_number = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Номер здания"
|
|
||||||
)
|
|
||||||
|
|
||||||
apartment_number = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Номер квартиры/офиса"
|
|
||||||
)
|
|
||||||
|
|
||||||
entrance = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Подъезд",
|
|
||||||
help_text="Номер подъезда/входа"
|
|
||||||
)
|
|
||||||
|
|
||||||
floor = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Этаж"
|
|
||||||
)
|
|
||||||
|
|
||||||
intercom_code = models.CharField(
|
|
||||||
max_length=100,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Код домофона",
|
|
||||||
help_text="Код домофона для входа в здание"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Дополнительная информация для доставки
|
|
||||||
delivery_instructions = models.TextField(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Инструкции для доставки",
|
|
||||||
help_text="Дополнительные инструкции для курьера"
|
|
||||||
)
|
|
||||||
|
|
||||||
confirm_address_with_recipient = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name="Уточнить адрес у получателя",
|
|
||||||
help_text="Курьер должен уточнить адрес у получателя перед доставкой"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Временные метки
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
|
||||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Адрес доставки"
|
|
||||||
verbose_name_plural = "Адреса доставки"
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['created_at']),
|
|
||||||
]
|
|
||||||
ordering = ['-created_at']
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
# Собираем компоненты адреса
|
|
||||||
address_parts = []
|
|
||||||
if self.street:
|
|
||||||
address_parts.append(self.street)
|
|
||||||
if self.building_number:
|
|
||||||
address_parts.append(self.building_number)
|
|
||||||
if self.apartment_number:
|
|
||||||
address_parts.append(f"кв/офис {self.apartment_number}")
|
|
||||||
|
|
||||||
address_line = ", ".join(address_parts) if address_parts else "Адрес не указан"
|
|
||||||
|
|
||||||
# Формируем строку с именем получателя
|
|
||||||
if self.recipient_name:
|
|
||||||
return f"{self.recipient_name} - {address_line}"
|
|
||||||
return address_line
|
|
||||||
|
|
||||||
@property
|
|
||||||
def full_address(self):
|
|
||||||
"""Полный адрес для доставки"""
|
|
||||||
# Собираем основные компоненты адреса
|
|
||||||
address_parts = []
|
|
||||||
if self.street:
|
|
||||||
address_parts.append(self.street)
|
|
||||||
if self.building_number:
|
|
||||||
address_parts.append(self.building_number)
|
|
||||||
|
|
||||||
# Если нет основных данных, возвращаем сообщение
|
|
||||||
if not address_parts:
|
|
||||||
return "Адрес не указан"
|
|
||||||
|
|
||||||
address = ", ".join(address_parts)
|
|
||||||
|
|
||||||
# Добавляем квартиру/офис
|
|
||||||
if self.apartment_number:
|
|
||||||
address += f", кв/офис {self.apartment_number}"
|
|
||||||
|
|
||||||
# Собираем дополнительные детали
|
|
||||||
details = []
|
|
||||||
if self.entrance:
|
|
||||||
details.append(f"подъезд {self.entrance}")
|
|
||||||
if self.floor:
|
|
||||||
details.append(f"этаж {self.floor}")
|
|
||||||
if details:
|
|
||||||
address += f" ({', '.join(details)})"
|
|
||||||
|
|
||||||
return address
|
|
||||||
|
|
||||||
|
|
||||||
class Order(models.Model):
|
|
||||||
"""
|
|
||||||
Заказ клиента для доставки цветов.
|
|
||||||
"""
|
|
||||||
# Основная информация
|
|
||||||
customer = models.ForeignKey(
|
|
||||||
Customer,
|
|
||||||
on_delete=models.PROTECT,
|
|
||||||
related_name='orders',
|
|
||||||
verbose_name="Клиент"
|
|
||||||
)
|
|
||||||
|
|
||||||
order_number = models.PositiveIntegerField(
|
|
||||||
unique=True,
|
|
||||||
editable=False,
|
|
||||||
verbose_name="Номер заказа",
|
|
||||||
help_text="Уникальный номер заказа"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Тип доставки
|
|
||||||
is_delivery = models.BooleanField(
|
|
||||||
default=True,
|
|
||||||
verbose_name="С доставкой",
|
|
||||||
help_text="True - доставка курьером, False - самовывоз"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Адрес доставки (для курьерской доставки)
|
|
||||||
delivery_address = models.OneToOneField(
|
|
||||||
Address,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='order',
|
|
||||||
verbose_name="Адрес доставки",
|
|
||||||
help_text="Обязательно для курьерской доставки"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Склад для самовывоза
|
|
||||||
pickup_warehouse = models.ForeignKey(
|
|
||||||
Warehouse,
|
|
||||||
on_delete=models.PROTECT,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='pickup_orders',
|
|
||||||
verbose_name="Склад для самовывоза",
|
|
||||||
help_text="Обязательно для самовывоза"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Дата и время доставки/самовывоза
|
|
||||||
delivery_date = models.DateField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Дата доставки/самовывоза",
|
|
||||||
help_text="Может быть заполнено позже"
|
|
||||||
)
|
|
||||||
|
|
||||||
delivery_time_start = models.TimeField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Время от",
|
|
||||||
help_text="Начало временного интервала"
|
|
||||||
)
|
|
||||||
|
|
||||||
delivery_time_end = models.TimeField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Время до",
|
|
||||||
help_text="Конец временного интервала"
|
|
||||||
)
|
|
||||||
|
|
||||||
delivery_cost = models.DecimalField(
|
|
||||||
max_digits=10,
|
|
||||||
decimal_places=2,
|
|
||||||
default=0,
|
|
||||||
verbose_name="Стоимость доставки",
|
|
||||||
help_text="0 для самовывоза"
|
|
||||||
)
|
|
||||||
|
|
||||||
is_custom_delivery_cost = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name="Стоимость доставки установлена вручную",
|
|
||||||
help_text="True если стоимость доставки была изменена вручную"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Статус заказа
|
|
||||||
status = models.ForeignKey(
|
|
||||||
'OrderStatus',
|
|
||||||
on_delete=models.PROTECT,
|
|
||||||
related_name='orders',
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Статус заказа"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Флаг для отслеживания возвратов
|
|
||||||
is_returned = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name="Возвращен",
|
|
||||||
help_text="True если заказ был выполнен, но потом отменен или возвращен клиентом"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Автосохранение (для черновиков)
|
|
||||||
last_autosave_at = models.DateTimeField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
verbose_name="Последнее автосохранение",
|
|
||||||
help_text="Время последнего автоматического сохранения черновика"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Оплата
|
|
||||||
PAYMENT_METHOD_CHOICES = [
|
|
||||||
('cash_to_courier', 'Наличные курьеру'),
|
|
||||||
('card_to_courier', 'Карта курьеру'),
|
|
||||||
('online', 'Онлайн оплата'),
|
|
||||||
('bank_transfer', 'Банковский перевод'),
|
|
||||||
]
|
|
||||||
|
|
||||||
payment_method = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
choices=PAYMENT_METHOD_CHOICES,
|
|
||||||
default='cash_to_courier',
|
|
||||||
verbose_name="Способ оплаты"
|
|
||||||
)
|
|
||||||
|
|
||||||
is_paid = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name="Оплачен"
|
|
||||||
)
|
|
||||||
|
|
||||||
total_amount = models.DecimalField(
|
|
||||||
max_digits=10,
|
|
||||||
decimal_places=2,
|
|
||||||
default=0,
|
|
||||||
verbose_name="Итоговая сумма заказа",
|
|
||||||
help_text="Общая сумма заказа включая доставку"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Скидки
|
|
||||||
discount_amount = models.DecimalField(
|
|
||||||
max_digits=10,
|
|
||||||
decimal_places=2,
|
|
||||||
default=0,
|
|
||||||
verbose_name="Сумма скидки",
|
|
||||||
help_text="Применяется вручную или через систему скидок"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Частичная оплата
|
|
||||||
amount_paid = models.DecimalField(
|
|
||||||
max_digits=10,
|
|
||||||
decimal_places=2,
|
|
||||||
default=0,
|
|
||||||
verbose_name="Оплачено",
|
|
||||||
help_text="Сумма, внесенная клиентом"
|
|
||||||
)
|
|
||||||
|
|
||||||
PAYMENT_STATUS_CHOICES = [
|
|
||||||
('unpaid', 'Не оплачен'),
|
|
||||||
('partial', 'Частично оплачен'),
|
|
||||||
('paid', 'Оплачен полностью'),
|
|
||||||
]
|
|
||||||
|
|
||||||
payment_status = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
choices=PAYMENT_STATUS_CHOICES,
|
|
||||||
default='unpaid',
|
|
||||||
verbose_name="Статус оплаты",
|
|
||||||
help_text="Обновляется автоматически при добавлении платежей"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Дополнительная информация
|
|
||||||
customer_is_recipient = models.BooleanField(
|
|
||||||
default=True,
|
|
||||||
verbose_name="Покупатель является получателем",
|
|
||||||
help_text="Если отмечено, данные получателя не требуются отдельно"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Данные получателя (если покупатель != получатель)
|
|
||||||
recipient_name = models.CharField(
|
|
||||||
max_length=200,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Имя получателя",
|
|
||||||
help_text="Заполняется, если покупатель не является получателем"
|
|
||||||
)
|
|
||||||
|
|
||||||
recipient_phone = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Телефон получателя",
|
|
||||||
help_text="Контактный телефон получателя"
|
|
||||||
)
|
|
||||||
|
|
||||||
is_anonymous = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name="Анонимная доставка",
|
|
||||||
help_text="Не сообщать получателю имя отправителя"
|
|
||||||
)
|
|
||||||
|
|
||||||
special_instructions = models.TextField(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name="Особые пожелания",
|
|
||||||
help_text="Комментарии и пожелания к заказу"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Временные метки
|
|
||||||
created_at = models.DateTimeField(
|
|
||||||
auto_now_add=True,
|
|
||||||
verbose_name="Дата создания"
|
|
||||||
)
|
|
||||||
|
|
||||||
updated_at = models.DateTimeField(
|
|
||||||
auto_now=True,
|
|
||||||
verbose_name="Дата обновления"
|
|
||||||
)
|
|
||||||
|
|
||||||
modified_by = models.ForeignKey(
|
|
||||||
CustomUser,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='modified_orders',
|
|
||||||
verbose_name="Изменен пользователем",
|
|
||||||
help_text="Последний пользователь, изменивший заказ"
|
|
||||||
)
|
|
||||||
|
|
||||||
# История изменений
|
|
||||||
history = HistoricalRecords()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Заказ"
|
|
||||||
verbose_name_plural = "Заказы"
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['customer']),
|
|
||||||
models.Index(fields=['status']),
|
|
||||||
models.Index(fields=['delivery_date']),
|
|
||||||
models.Index(fields=['is_delivery']),
|
|
||||||
models.Index(fields=['payment_status']),
|
|
||||||
models.Index(fields=['created_at']),
|
|
||||||
models.Index(fields=['order_number']),
|
|
||||||
models.Index(fields=['is_custom_delivery_cost']),
|
|
||||||
]
|
|
||||||
ordering = ['-created_at']
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Заказ #{self.order_number} - {self.customer}"
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
# Генерируем уникальный номер заказа при создании (начиная с 100 для 3-значного поиска)
|
|
||||||
if not self.order_number:
|
|
||||||
last_order = Order.objects.order_by('-order_number').first()
|
|
||||||
if last_order:
|
|
||||||
# Если ранее нумерация была ниже 100, начинаем с 100; иначе инкремент
|
|
||||||
self.order_number = max(last_order.order_number + 1, 100)
|
|
||||||
else:
|
|
||||||
self.order_number = 100
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
"""Валидация модели"""
|
|
||||||
super().clean()
|
|
||||||
|
|
||||||
# Проверка: для доставки обязателен адрес
|
|
||||||
if self.is_delivery and not self.delivery_address:
|
|
||||||
raise ValidationError({
|
|
||||||
'delivery_address': 'Для доставки необходимо указать адрес доставки'
|
|
||||||
})
|
|
||||||
|
|
||||||
# Проверка: для самовывоза обязателен склад
|
|
||||||
if not self.is_delivery and not self.pickup_warehouse:
|
|
||||||
raise ValidationError({
|
|
||||||
'pickup_warehouse': 'Для самовывоза необходимо выбрать склад'
|
|
||||||
})
|
|
||||||
|
|
||||||
# Проверка: время окончания должно быть позже времени начала
|
|
||||||
if self.delivery_time_start and self.delivery_time_end:
|
|
||||||
if self.delivery_time_end <= self.delivery_time_start:
|
|
||||||
raise ValidationError({
|
|
||||||
'delivery_time_end': 'Время окончания должно быть позже времени начала'
|
|
||||||
})
|
|
||||||
|
|
||||||
def get_delivery_cost(self):
|
|
||||||
"""
|
|
||||||
Возвращает стоимость доставки:
|
|
||||||
- Если установлена вручную - использует ручное значение
|
|
||||||
- Если автоматическая - вычисляет на основе правил
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Decimal: Стоимость доставки
|
|
||||||
"""
|
|
||||||
if self.is_custom_delivery_cost:
|
|
||||||
return self.delivery_cost
|
|
||||||
else:
|
|
||||||
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
|
|
||||||
return DeliveryCostCalculator.calculate(self)
|
|
||||||
|
|
||||||
def set_delivery_cost(self, cost, is_custom=True):
|
|
||||||
"""
|
|
||||||
Устанавливает стоимость доставки.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cost: Новая стоимость доставки (Decimal)
|
|
||||||
is_custom: True если устанавливается вручную, False если автоматически
|
|
||||||
"""
|
|
||||||
self.delivery_cost = cost
|
|
||||||
self.is_custom_delivery_cost = is_custom
|
|
||||||
|
|
||||||
def reset_delivery_cost(self):
|
|
||||||
"""
|
|
||||||
Сбрасывает стоимость доставки на автоматический расчет.
|
|
||||||
"""
|
|
||||||
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
|
|
||||||
self.delivery_cost = DeliveryCostCalculator.calculate(self)
|
|
||||||
self.is_custom_delivery_cost = False
|
|
||||||
|
|
||||||
def recalculate_delivery_cost(self):
|
|
||||||
"""
|
|
||||||
Пересчитывает стоимость доставки, если она не установлена вручную.
|
|
||||||
Используется при изменении параметров заказа (товаров, адреса и т.д.)
|
|
||||||
"""
|
|
||||||
if not self.is_custom_delivery_cost:
|
|
||||||
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
|
|
||||||
self.delivery_cost = DeliveryCostCalculator.calculate(self)
|
|
||||||
|
|
||||||
def calculate_total(self):
|
|
||||||
"""Рассчитывает итоговую сумму заказа"""
|
|
||||||
items_total = sum(item.get_total_price() for item in self.items.all())
|
|
||||||
|
|
||||||
# Пересчитываем стоимость доставки если она автоматическая
|
|
||||||
self.recalculate_delivery_cost()
|
|
||||||
|
|
||||||
subtotal = items_total + self.delivery_cost
|
|
||||||
self.total_amount = subtotal - self.discount_amount
|
|
||||||
return self.total_amount
|
|
||||||
|
|
||||||
def update_payment_status(self):
|
|
||||||
"""Автоматически обновляет статус оплаты на основе amount_paid"""
|
|
||||||
if self.amount_paid >= self.total_amount:
|
|
||||||
self.payment_status = 'paid'
|
|
||||||
self.is_paid = True
|
|
||||||
elif self.amount_paid > 0:
|
|
||||||
self.payment_status = 'partial'
|
|
||||||
self.is_paid = False
|
|
||||||
else:
|
|
||||||
self.payment_status = 'unpaid'
|
|
||||||
self.is_paid = False
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def is_draft(self):
|
|
||||||
"""Проверяет, является ли заказ черновиком"""
|
|
||||||
return self.status and self.status.code == 'draft'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def amount_due(self):
|
|
||||||
"""Остаток к оплате"""
|
|
||||||
return max(self.total_amount - self.amount_paid, 0)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def delivery_cost_display(self):
|
|
||||||
"""
|
|
||||||
Возвращает строку для отображения стоимости доставки с пометкой.
|
|
||||||
Полезно в админке и шаблонах.
|
|
||||||
"""
|
|
||||||
cost = self.get_delivery_cost()
|
|
||||||
suffix = " (ручная)" if self.is_custom_delivery_cost else " (авто)"
|
|
||||||
return f"{cost} руб.{suffix}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def delivery_info(self):
|
|
||||||
"""Информация о доставке для отображения"""
|
|
||||||
if self.is_delivery:
|
|
||||||
if self.delivery_address:
|
|
||||||
return f"Доставка по адресу: {self.delivery_address.full_address}"
|
|
||||||
return "Доставка (адрес не указан)"
|
|
||||||
else:
|
|
||||||
if self.pickup_warehouse:
|
|
||||||
return f"Самовывоз со склада: {self.pickup_warehouse.name}"
|
|
||||||
return "Самовывоз (склад не указан)"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def delivery_time_window(self):
|
|
||||||
"""Временное окно доставки"""
|
|
||||||
if self.delivery_time_start and self.delivery_time_end:
|
|
||||||
return f"{self.delivery_time_start.strftime('%H:%M')} - {self.delivery_time_end.strftime('%H:%M')}"
|
|
||||||
return "Время не указано"
|
|
||||||
|
|
||||||
|
|
||||||
class OrderItem(models.Model):
|
|
||||||
"""
|
|
||||||
Позиция (товар) в заказе.
|
|
||||||
Хранит информацию о товаре или комплекте, количестве и цене на момент заказа.
|
|
||||||
"""
|
|
||||||
order = models.ForeignKey(
|
|
||||||
Order,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='items',
|
|
||||||
verbose_name="Заказ"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Товар или комплект (один из двух должен быть заполнен)
|
|
||||||
product = models.ForeignKey(
|
|
||||||
Product,
|
|
||||||
on_delete=models.PROTECT,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='order_items',
|
|
||||||
verbose_name="Товар"
|
|
||||||
)
|
|
||||||
|
|
||||||
product_kit = models.ForeignKey(
|
|
||||||
ProductKit,
|
|
||||||
on_delete=models.PROTECT,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='order_items',
|
|
||||||
verbose_name="Комплект товаров"
|
|
||||||
)
|
|
||||||
|
|
||||||
quantity = models.PositiveIntegerField(
|
|
||||||
default=1,
|
|
||||||
verbose_name="Количество"
|
|
||||||
)
|
|
||||||
|
|
||||||
price = models.DecimalField(
|
|
||||||
max_digits=10,
|
|
||||||
decimal_places=2,
|
|
||||||
verbose_name="Цена за единицу",
|
|
||||||
help_text="Цена на момент создания заказа (фиксируется)"
|
|
||||||
)
|
|
||||||
|
|
||||||
is_custom_price = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name="Цена изменена вручную",
|
|
||||||
help_text="True если цена была изменена вручную при создании заказа"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Витринные продажи
|
|
||||||
is_from_showcase = models.BooleanField(
|
|
||||||
default=False,
|
|
||||||
verbose_name="С витрины",
|
|
||||||
help_text="True если товар продан с витрины"
|
|
||||||
)
|
|
||||||
|
|
||||||
showcase = models.ForeignKey(
|
|
||||||
'inventory.Showcase',
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='order_items',
|
|
||||||
verbose_name="Витрина",
|
|
||||||
help_text="Витрина, с которой был продан товар"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Временные метки
|
|
||||||
created_at = models.DateTimeField(
|
|
||||||
auto_now_add=True,
|
|
||||||
verbose_name="Дата добавления"
|
|
||||||
)
|
|
||||||
|
|
||||||
# История изменений
|
|
||||||
history = HistoricalRecords()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Позиция заказа"
|
|
||||||
verbose_name_plural = "Позиции заказа"
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['order']),
|
|
||||||
models.Index(fields=['product']),
|
|
||||||
models.Index(fields=['product_kit']),
|
|
||||||
models.Index(fields=['is_from_showcase']),
|
|
||||||
models.Index(fields=['showcase']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
item_name = ""
|
|
||||||
if self.product:
|
|
||||||
item_name = self.product.name
|
|
||||||
elif self.product_kit:
|
|
||||||
item_name = self.product_kit.name
|
|
||||||
return f"{item_name} x{self.quantity} в заказе #{self.order.order_number}"
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
"""Валидация модели"""
|
|
||||||
super().clean()
|
|
||||||
|
|
||||||
# Проверка: должен быть заполнен либо product, либо product_kit
|
|
||||||
if not self.product and not self.product_kit:
|
|
||||||
raise ValidationError(
|
|
||||||
'Необходимо указать либо товар, либо комплект товаров'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Проверка: не должны быть заполнены оба поля одновременно
|
|
||||||
if self.product and self.product_kit:
|
|
||||||
raise ValidationError(
|
|
||||||
'Нельзя указать одновременно и товар, и комплект'
|
|
||||||
)
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
# Автоматически фиксируем цену при создании, если она не указана
|
|
||||||
if not self.price:
|
|
||||||
if self.product:
|
|
||||||
self.price = self.product.price
|
|
||||||
elif self.product_kit:
|
|
||||||
self.price = self.product_kit.price
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_total_price(self):
|
|
||||||
"""Возвращает общую стоимость позиции"""
|
|
||||||
return self.price * self.quantity
|
|
||||||
|
|
||||||
@property
|
|
||||||
def item_name(self):
|
|
||||||
"""Название товара/комплекта"""
|
|
||||||
if self.product:
|
|
||||||
return self.product.name
|
|
||||||
elif self.product_kit:
|
|
||||||
return self.product_kit.name
|
|
||||||
return "Не указано"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def original_price(self):
|
|
||||||
"""Оригинальная цена товара/комплекта из каталога"""
|
|
||||||
if self.product:
|
|
||||||
return self.product.actual_price
|
|
||||||
elif self.product_kit:
|
|
||||||
return self.product_kit.actual_price
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def price_difference(self):
|
|
||||||
"""Разница между установленной ценой и оригинальной"""
|
|
||||||
if self.is_custom_price and self.original_price:
|
|
||||||
return self.price - self.original_price
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
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.CharField(
|
|
||||||
max_length=20,
|
|
||||||
choices=Order.PAYMENT_METHOD_CHOICES,
|
|
||||||
verbose_name="Способ оплаты"
|
|
||||||
)
|
|
||||||
|
|
||||||
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):
|
|
||||||
"""При сохранении платежа обновляем сумму оплаты в заказе"""
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
# Пересчитываем общую сумму оплаты в заказе
|
|
||||||
self.order.amount_paid = sum(p.amount for p in self.order.payments.all())
|
|
||||||
self.order.update_payment_status()
|
|
||||||
35
myproject/orders/models/__init__.py
Normal file
35
myproject/orders/models/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
Модели приложения Orders.
|
||||||
|
|
||||||
|
Структура:
|
||||||
|
- OrderStatus: Статусы заказов
|
||||||
|
- Address: Адреса доставки
|
||||||
|
- Order: Главная модель заказа
|
||||||
|
- OrderItem: Позиции в заказе
|
||||||
|
- PaymentMethod: Способы оплаты (справочник)
|
||||||
|
- Payment: Платежи по заказам (поддержка смешанной оплаты)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Порядок импортов по зависимостям:
|
||||||
|
# 1. Независимые модели (справочники)
|
||||||
|
from .status import OrderStatus
|
||||||
|
from .payment import PaymentMethod
|
||||||
|
|
||||||
|
# 2. Модели с зависимостями от справочников
|
||||||
|
from .address import Address
|
||||||
|
|
||||||
|
# 3. Главная модель Order (зависит от Status, Address)
|
||||||
|
from .order import Order
|
||||||
|
|
||||||
|
# 4. Зависимые модели
|
||||||
|
from .order_item import OrderItem
|
||||||
|
from .payment import Payment
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'OrderStatus',
|
||||||
|
'Address',
|
||||||
|
'Order',
|
||||||
|
'OrderItem',
|
||||||
|
'PaymentMethod',
|
||||||
|
'Payment',
|
||||||
|
]
|
||||||
142
myproject/orders/models/address.py
Normal file
142
myproject/orders/models/address.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Address(models.Model):
|
||||||
|
"""
|
||||||
|
Модель адреса доставки для заказа цветочного магазина в Минске.
|
||||||
|
Адрес принадлежит конкретному заказу доставки.
|
||||||
|
"""
|
||||||
|
# Информация о получателе
|
||||||
|
recipient_name = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Имя получателя",
|
||||||
|
help_text="Имя человека, которому будет доставлен заказ"
|
||||||
|
)
|
||||||
|
|
||||||
|
recipient_phone = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Телефон получателя",
|
||||||
|
help_text="Контактный телефон получателя для уточнения адреса"
|
||||||
|
)
|
||||||
|
|
||||||
|
street = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Улица"
|
||||||
|
)
|
||||||
|
|
||||||
|
building_number = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Номер здания"
|
||||||
|
)
|
||||||
|
|
||||||
|
apartment_number = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Номер квартиры/офиса"
|
||||||
|
)
|
||||||
|
|
||||||
|
entrance = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Подъезд",
|
||||||
|
help_text="Номер подъезда/входа"
|
||||||
|
)
|
||||||
|
|
||||||
|
floor = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Этаж"
|
||||||
|
)
|
||||||
|
|
||||||
|
intercom_code = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Код домофона",
|
||||||
|
help_text="Код домофона для входа в здание"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Дополнительная информация для доставки
|
||||||
|
delivery_instructions = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Инструкции для доставки",
|
||||||
|
help_text="Дополнительные инструкции для курьера"
|
||||||
|
)
|
||||||
|
|
||||||
|
confirm_address_with_recipient = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Уточнить адрес у получателя",
|
||||||
|
help_text="Курьер должен уточнить адрес у получателя перед доставкой"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Временные метки
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||||
|
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Адрес доставки"
|
||||||
|
verbose_name_plural = "Адреса доставки"
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['created_at']),
|
||||||
|
]
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
# Собираем компоненты адреса
|
||||||
|
address_parts = []
|
||||||
|
if self.street:
|
||||||
|
address_parts.append(self.street)
|
||||||
|
if self.building_number:
|
||||||
|
address_parts.append(self.building_number)
|
||||||
|
if self.apartment_number:
|
||||||
|
address_parts.append(f"кв/офис {self.apartment_number}")
|
||||||
|
|
||||||
|
address_line = ", ".join(address_parts) if address_parts else "Адрес не указан"
|
||||||
|
|
||||||
|
# Формируем строку с именем получателя
|
||||||
|
if self.recipient_name:
|
||||||
|
return f"{self.recipient_name} - {address_line}"
|
||||||
|
return address_line
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_address(self):
|
||||||
|
"""Полный адрес для доставки"""
|
||||||
|
# Собираем основные компоненты адреса
|
||||||
|
address_parts = []
|
||||||
|
if self.street:
|
||||||
|
address_parts.append(self.street)
|
||||||
|
if self.building_number:
|
||||||
|
address_parts.append(self.building_number)
|
||||||
|
|
||||||
|
# Если нет основных данных, возвращаем сообщение
|
||||||
|
if not address_parts:
|
||||||
|
return "Адрес не указан"
|
||||||
|
|
||||||
|
address = ", ".join(address_parts)
|
||||||
|
|
||||||
|
# Добавляем квартиру/офис
|
||||||
|
if self.apartment_number:
|
||||||
|
address += f", кв/офис {self.apartment_number}"
|
||||||
|
|
||||||
|
# Собираем дополнительные детали
|
||||||
|
details = []
|
||||||
|
if self.entrance:
|
||||||
|
details.append(f"подъезд {self.entrance}")
|
||||||
|
if self.floor:
|
||||||
|
details.append(f"этаж {self.floor}")
|
||||||
|
if details:
|
||||||
|
address += f" ({', '.join(details)})"
|
||||||
|
|
||||||
|
return address
|
||||||
388
myproject/orders/models/order.py
Normal file
388
myproject/orders/models/order.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from accounts.models import CustomUser
|
||||||
|
from customers.models import Customer
|
||||||
|
from inventory.models import Warehouse
|
||||||
|
from simple_history.models import HistoricalRecords
|
||||||
|
from .status import OrderStatus
|
||||||
|
from .address import Address
|
||||||
|
|
||||||
|
|
||||||
|
class Order(models.Model):
|
||||||
|
"""
|
||||||
|
Заказ клиента для доставки цветов.
|
||||||
|
|
||||||
|
ВАЖНО: Поле payment_method УДАЛЕНО для поддержки смешанной оплаты.
|
||||||
|
Используйте модель Payment (один Order → много Payment) для платежей.
|
||||||
|
"""
|
||||||
|
# Основная информация
|
||||||
|
customer = models.ForeignKey(
|
||||||
|
Customer,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='orders',
|
||||||
|
verbose_name="Клиент"
|
||||||
|
)
|
||||||
|
|
||||||
|
order_number = models.PositiveIntegerField(
|
||||||
|
unique=True,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="Номер заказа",
|
||||||
|
help_text="Уникальный номер заказа"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Тип доставки
|
||||||
|
is_delivery = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
verbose_name="С доставкой",
|
||||||
|
help_text="True - доставка курьером, False - самовывоз"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Адрес доставки (для курьерской доставки)
|
||||||
|
delivery_address = models.OneToOneField(
|
||||||
|
Address,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='order',
|
||||||
|
verbose_name="Адрес доставки",
|
||||||
|
help_text="Обязательно для курьерской доставки"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Склад для самовывоза
|
||||||
|
pickup_warehouse = models.ForeignKey(
|
||||||
|
Warehouse,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='pickup_orders',
|
||||||
|
verbose_name="Склад для самовывоза",
|
||||||
|
help_text="Обязательно для самовывоза"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Дата и время доставки/самовывоза
|
||||||
|
delivery_date = models.DateField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Дата доставки/самовывоза",
|
||||||
|
help_text="Может быть заполнено позже"
|
||||||
|
)
|
||||||
|
|
||||||
|
delivery_time_start = models.TimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Время от",
|
||||||
|
help_text="Начало временного интервала"
|
||||||
|
)
|
||||||
|
|
||||||
|
delivery_time_end = models.TimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Время до",
|
||||||
|
help_text="Конец временного интервала"
|
||||||
|
)
|
||||||
|
|
||||||
|
delivery_cost = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
default=0,
|
||||||
|
verbose_name="Стоимость доставки",
|
||||||
|
help_text="0 для самовывоза"
|
||||||
|
)
|
||||||
|
|
||||||
|
is_custom_delivery_cost = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Стоимость доставки установлена вручную",
|
||||||
|
help_text="True если стоимость доставки была изменена вручную"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Статус заказа
|
||||||
|
status = models.ForeignKey(
|
||||||
|
'OrderStatus',
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='orders',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Статус заказа"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Флаг для отслеживания возвратов
|
||||||
|
is_returned = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Возвращен",
|
||||||
|
help_text="True если заказ был выполнен, но потом отменен или возвращен клиентом"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Автосохранение (для черновиков)
|
||||||
|
last_autosave_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Последнее автосохранение",
|
||||||
|
help_text="Время последнего автоматического сохранения черновика"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Оплата
|
||||||
|
# УДАЛЕНО: PAYMENT_METHOD_CHOICES и payment_method поле
|
||||||
|
# Вместо этого используйте модель Payment для смешанной оплаты
|
||||||
|
|
||||||
|
is_paid = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Оплачен"
|
||||||
|
)
|
||||||
|
|
||||||
|
total_amount = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
default=0,
|
||||||
|
verbose_name="Итоговая сумма заказа",
|
||||||
|
help_text="Общая сумма заказа включая доставку"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Скидки
|
||||||
|
discount_amount = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
default=0,
|
||||||
|
verbose_name="Сумма скидки",
|
||||||
|
help_text="Применяется вручную или через систему скидок"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Частичная оплата
|
||||||
|
amount_paid = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
default=0,
|
||||||
|
verbose_name="Оплачено",
|
||||||
|
help_text="Сумма, внесенная клиентом"
|
||||||
|
)
|
||||||
|
|
||||||
|
PAYMENT_STATUS_CHOICES = [
|
||||||
|
('unpaid', 'Не оплачен'),
|
||||||
|
('partial', 'Частично оплачен'),
|
||||||
|
('paid', 'Оплачен полностью'),
|
||||||
|
]
|
||||||
|
|
||||||
|
payment_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=PAYMENT_STATUS_CHOICES,
|
||||||
|
default='unpaid',
|
||||||
|
verbose_name="Статус оплаты",
|
||||||
|
help_text="Обновляется автоматически при добавлении платежей"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Дополнительная информация
|
||||||
|
customer_is_recipient = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
verbose_name="Покупатель является получателем",
|
||||||
|
help_text="Если отмечено, данные получателя не требуются отдельно"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Данные получателя (если покупатель != получатель)
|
||||||
|
recipient_name = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Имя получателя",
|
||||||
|
help_text="Заполняется, если покупатель не является получателем"
|
||||||
|
)
|
||||||
|
|
||||||
|
recipient_phone = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Телефон получателя",
|
||||||
|
help_text="Контактный телефон получателя"
|
||||||
|
)
|
||||||
|
|
||||||
|
is_anonymous = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Анонимная доставка",
|
||||||
|
help_text="Не сообщать получателю имя отправителя"
|
||||||
|
)
|
||||||
|
|
||||||
|
special_instructions = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Особые пожелания",
|
||||||
|
help_text="Комментарии и пожелания к заказу"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Временные метки
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name="Дата создания"
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_at = models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
verbose_name="Дата обновления"
|
||||||
|
)
|
||||||
|
|
||||||
|
modified_by = models.ForeignKey(
|
||||||
|
CustomUser,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='modified_orders',
|
||||||
|
verbose_name="Изменен пользователем",
|
||||||
|
help_text="Последний пользователь, изменивший заказ"
|
||||||
|
)
|
||||||
|
|
||||||
|
# История изменений
|
||||||
|
history = HistoricalRecords()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Заказ"
|
||||||
|
verbose_name_plural = "Заказы"
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['customer']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['delivery_date']),
|
||||||
|
models.Index(fields=['is_delivery']),
|
||||||
|
models.Index(fields=['payment_status']),
|
||||||
|
models.Index(fields=['created_at']),
|
||||||
|
models.Index(fields=['order_number']),
|
||||||
|
models.Index(fields=['is_custom_delivery_cost']),
|
||||||
|
]
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Заказ #{self.order_number} - {self.customer}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Генерируем уникальный номер заказа при создании (начиная с 100 для 3-значного поиска)
|
||||||
|
if not self.order_number:
|
||||||
|
last_order = Order.objects.order_by('-order_number').first()
|
||||||
|
if last_order:
|
||||||
|
# Если ранее нумерация была ниже 100, начинаем с 100; иначе инкремент
|
||||||
|
self.order_number = max(last_order.order_number + 1, 100)
|
||||||
|
else:
|
||||||
|
self.order_number = 100
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Валидация модели"""
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
# Проверка: для доставки обязателен адрес
|
||||||
|
if self.is_delivery and not self.delivery_address:
|
||||||
|
raise ValidationError({
|
||||||
|
'delivery_address': 'Для доставки необходимо указать адрес доставки'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Проверка: для самовывоза обязателен склад
|
||||||
|
if not self.is_delivery and not self.pickup_warehouse:
|
||||||
|
raise ValidationError({
|
||||||
|
'pickup_warehouse': 'Для самовывоза необходимо выбрать склад'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Проверка: время окончания должно быть позже времени начала
|
||||||
|
if self.delivery_time_start and self.delivery_time_end:
|
||||||
|
if self.delivery_time_end <= self.delivery_time_start:
|
||||||
|
raise ValidationError({
|
||||||
|
'delivery_time_end': 'Время окончания должно быть позже времени начала'
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_delivery_cost(self):
|
||||||
|
"""
|
||||||
|
Возвращает стоимость доставки:
|
||||||
|
- Если установлена вручную - использует ручное значение
|
||||||
|
- Если автоматическая - вычисляет на основе правил
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decimal: Стоимость доставки
|
||||||
|
"""
|
||||||
|
if self.is_custom_delivery_cost:
|
||||||
|
return self.delivery_cost
|
||||||
|
else:
|
||||||
|
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
|
||||||
|
return DeliveryCostCalculator.calculate(self)
|
||||||
|
|
||||||
|
def set_delivery_cost(self, cost, is_custom=True):
|
||||||
|
"""
|
||||||
|
Устанавливает стоимость доставки.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cost: Новая стоимость доставки (Decimal)
|
||||||
|
is_custom: True если устанавливается вручную, False если автоматически
|
||||||
|
"""
|
||||||
|
self.delivery_cost = cost
|
||||||
|
self.is_custom_delivery_cost = is_custom
|
||||||
|
|
||||||
|
def reset_delivery_cost(self):
|
||||||
|
"""
|
||||||
|
Сбрасывает стоимость доставки на автоматический расчет.
|
||||||
|
"""
|
||||||
|
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
|
||||||
|
self.delivery_cost = DeliveryCostCalculator.calculate(self)
|
||||||
|
self.is_custom_delivery_cost = False
|
||||||
|
|
||||||
|
def recalculate_delivery_cost(self):
|
||||||
|
"""
|
||||||
|
Пересчитывает стоимость доставки, если она не установлена вручную.
|
||||||
|
Используется при изменении параметров заказа (товаров, адреса и т.д.)
|
||||||
|
"""
|
||||||
|
if not self.is_custom_delivery_cost:
|
||||||
|
from orders.services.delivery_cost_calculator import DeliveryCostCalculator
|
||||||
|
self.delivery_cost = DeliveryCostCalculator.calculate(self)
|
||||||
|
|
||||||
|
def calculate_total(self):
|
||||||
|
"""Рассчитывает итоговую сумму заказа"""
|
||||||
|
items_total = sum(item.get_total_price() for item in self.items.all())
|
||||||
|
|
||||||
|
# Пересчитываем стоимость доставки если она автоматическая
|
||||||
|
self.recalculate_delivery_cost()
|
||||||
|
|
||||||
|
subtotal = items_total + self.delivery_cost
|
||||||
|
self.total_amount = subtotal - self.discount_amount
|
||||||
|
return self.total_amount
|
||||||
|
|
||||||
|
def update_payment_status(self):
|
||||||
|
"""Автоматически обновляет статус оплаты на основе amount_paid"""
|
||||||
|
if self.amount_paid >= self.total_amount:
|
||||||
|
self.payment_status = 'paid'
|
||||||
|
self.is_paid = True
|
||||||
|
elif self.amount_paid > 0:
|
||||||
|
self.payment_status = 'partial'
|
||||||
|
self.is_paid = False
|
||||||
|
else:
|
||||||
|
self.payment_status = 'unpaid'
|
||||||
|
self.is_paid = False
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def is_draft(self):
|
||||||
|
"""Проверяет, является ли заказ черновиком"""
|
||||||
|
return self.status and self.status.code == 'draft'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def amount_due(self):
|
||||||
|
"""Остаток к оплате"""
|
||||||
|
return max(self.total_amount - self.amount_paid, 0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def delivery_cost_display(self):
|
||||||
|
"""
|
||||||
|
Возвращает строку для отображения стоимости доставки с пометкой.
|
||||||
|
Полезно в админке и шаблонах.
|
||||||
|
"""
|
||||||
|
cost = self.get_delivery_cost()
|
||||||
|
suffix = " (ручная)" if self.is_custom_delivery_cost else " (авто)"
|
||||||
|
return f"{cost} руб.{suffix}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def delivery_info(self):
|
||||||
|
"""Информация о доставке для отображения"""
|
||||||
|
if self.is_delivery:
|
||||||
|
if self.delivery_address:
|
||||||
|
return f"Доставка по адресу: {self.delivery_address.full_address}"
|
||||||
|
return "Доставка (адрес не указан)"
|
||||||
|
else:
|
||||||
|
if self.pickup_warehouse:
|
||||||
|
return f"Самовывоз со склада: {self.pickup_warehouse.name}"
|
||||||
|
return "Самовывоз (склад не указан)"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def delivery_time_window(self):
|
||||||
|
"""Временное окно доставки"""
|
||||||
|
if self.delivery_time_start and self.delivery_time_end:
|
||||||
|
return f"{self.delivery_time_start.strftime('%H:%M')} - {self.delivery_time_end.strftime('%H:%M')}"
|
||||||
|
return "Время не указано"
|
||||||
154
myproject/orders/models/order_item.py
Normal file
154
myproject/orders/models/order_item.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from products.models import Product, ProductKit
|
||||||
|
from simple_history.models import HistoricalRecords
|
||||||
|
from .order import Order
|
||||||
|
|
||||||
|
|
||||||
|
class OrderItem(models.Model):
|
||||||
|
"""
|
||||||
|
Позиция (товар) в заказе.
|
||||||
|
Хранит информацию о товаре или комплекте, количестве и цене на момент заказа.
|
||||||
|
"""
|
||||||
|
order = models.ForeignKey(
|
||||||
|
Order,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='items',
|
||||||
|
verbose_name="Заказ"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Товар или комплект (один из двух должен быть заполнен)
|
||||||
|
product = models.ForeignKey(
|
||||||
|
Product,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='order_items',
|
||||||
|
verbose_name="Товар"
|
||||||
|
)
|
||||||
|
|
||||||
|
product_kit = models.ForeignKey(
|
||||||
|
ProductKit,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='order_items',
|
||||||
|
verbose_name="Комплект товаров"
|
||||||
|
)
|
||||||
|
|
||||||
|
quantity = models.PositiveIntegerField(
|
||||||
|
default=1,
|
||||||
|
verbose_name="Количество"
|
||||||
|
)
|
||||||
|
|
||||||
|
price = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
verbose_name="Цена за единицу",
|
||||||
|
help_text="Цена на момент создания заказа (фиксируется)"
|
||||||
|
)
|
||||||
|
|
||||||
|
is_custom_price = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Цена изменена вручную",
|
||||||
|
help_text="True если цена была изменена вручную при создании заказа"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Витринные продажи
|
||||||
|
is_from_showcase = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="С витрины",
|
||||||
|
help_text="True если товар продан с витрины"
|
||||||
|
)
|
||||||
|
|
||||||
|
showcase = models.ForeignKey(
|
||||||
|
'inventory.Showcase',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='order_items',
|
||||||
|
verbose_name="Витрина",
|
||||||
|
help_text="Витрина, с которой был продан товар"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Временные метки
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name="Дата добавления"
|
||||||
|
)
|
||||||
|
|
||||||
|
# История изменений
|
||||||
|
history = HistoricalRecords()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Позиция заказа"
|
||||||
|
verbose_name_plural = "Позиции заказа"
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['order']),
|
||||||
|
models.Index(fields=['product']),
|
||||||
|
models.Index(fields=['product_kit']),
|
||||||
|
models.Index(fields=['is_from_showcase']),
|
||||||
|
models.Index(fields=['showcase']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
item_name = ""
|
||||||
|
if self.product:
|
||||||
|
item_name = self.product.name
|
||||||
|
elif self.product_kit:
|
||||||
|
item_name = self.product_kit.name
|
||||||
|
return f"{item_name} x{self.quantity} в заказе #{self.order.order_number}"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Валидация модели"""
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
# Проверка: должен быть заполнен либо product, либо product_kit
|
||||||
|
if not self.product and not self.product_kit:
|
||||||
|
raise ValidationError(
|
||||||
|
'Необходимо указать либо товар, либо комплект товаров'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверка: не должны быть заполнены оба поля одновременно
|
||||||
|
if self.product and self.product_kit:
|
||||||
|
raise ValidationError(
|
||||||
|
'Нельзя указать одновременно и товар, и комплект'
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Автоматически фиксируем цену при создании, если она не указана
|
||||||
|
if not self.price:
|
||||||
|
if self.product:
|
||||||
|
self.price = self.product.price
|
||||||
|
elif self.product_kit:
|
||||||
|
self.price = self.product_kit.price
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_total_price(self):
|
||||||
|
"""Возвращает общую стоимость позиции"""
|
||||||
|
return self.price * self.quantity
|
||||||
|
|
||||||
|
@property
|
||||||
|
def item_name(self):
|
||||||
|
"""Название товара/комплекта"""
|
||||||
|
if self.product:
|
||||||
|
return self.product.name
|
||||||
|
elif self.product_kit:
|
||||||
|
return self.product_kit.name
|
||||||
|
return "Не указано"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def original_price(self):
|
||||||
|
"""Оригинальная цена товара/комплекта из каталога"""
|
||||||
|
if self.product:
|
||||||
|
return self.product.actual_price
|
||||||
|
elif self.product_kit:
|
||||||
|
return self.product_kit.actual_price
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def price_difference(self):
|
||||||
|
"""Разница между установленной ценой и оригинальной"""
|
||||||
|
if self.is_custom_price and self.original_price:
|
||||||
|
return self.price - self.original_price
|
||||||
|
return None
|
||||||
144
myproject/orders/models/payment.py
Normal file
144
myproject/orders/models/payment.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""При сохранении платежа обновляем сумму оплаты в заказе"""
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
# Пересчитываем общую сумму оплаты в заказе
|
||||||
|
self.order.amount_paid = sum(p.amount for p in self.order.payments.all())
|
||||||
|
self.order.update_payment_status()
|
||||||
100
myproject/orders/models/status.py
Normal file
100
myproject/orders/models/status.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
from django.db import models
|
||||||
|
from accounts.models import CustomUser
|
||||||
|
|
||||||
|
|
||||||
|
class OrderStatus(models.Model):
|
||||||
|
"""
|
||||||
|
Статус заказа, управляется отдельно для каждого тенанта.
|
||||||
|
Благодаря django-tenants в TENANT_APPS, данные изолированы по схемам.
|
||||||
|
"""
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
verbose_name="Название статуса"
|
||||||
|
)
|
||||||
|
|
||||||
|
code = models.SlugField(
|
||||||
|
unique=True,
|
||||||
|
verbose_name="Код статуса",
|
||||||
|
help_text="Уникальный идентификатор (например: 'completed', 'cancelled')"
|
||||||
|
)
|
||||||
|
|
||||||
|
label = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
verbose_name="Метка для отображения",
|
||||||
|
blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
is_system = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Системный статус",
|
||||||
|
help_text="True для встроенных статусов (draft, completed, cancelled)"
|
||||||
|
)
|
||||||
|
|
||||||
|
is_positive_end = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Положительный исход сделки",
|
||||||
|
help_text="True если это финальный успешный статус (Выполнен)"
|
||||||
|
)
|
||||||
|
|
||||||
|
is_negative_end = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Отрицательный исход сделки",
|
||||||
|
help_text="True если это финальный отрицательный статус (Отменен)"
|
||||||
|
)
|
||||||
|
|
||||||
|
order = models.PositiveIntegerField(
|
||||||
|
default=0,
|
||||||
|
verbose_name="Порядок отображения"
|
||||||
|
)
|
||||||
|
|
||||||
|
color = models.CharField(
|
||||||
|
max_length=7,
|
||||||
|
blank=True,
|
||||||
|
default='#808080',
|
||||||
|
verbose_name="Цвет (hex)",
|
||||||
|
help_text="Например: #FF5733"
|
||||||
|
)
|
||||||
|
|
||||||
|
description = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Описание"
|
||||||
|
)
|
||||||
|
|
||||||
|
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_order_statuses',
|
||||||
|
verbose_name="Создано"
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_by = models.ForeignKey(
|
||||||
|
CustomUser,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='updated_order_statuses',
|
||||||
|
verbose_name="Последнее изменение"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Статус заказа"
|
||||||
|
verbose_name_plural = "Статусы заказов"
|
||||||
|
ordering = ['order', 'name']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['code']),
|
||||||
|
models.Index(fields=['is_system']),
|
||||||
|
models.Index(fields=['order']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def orders_count(self):
|
||||||
|
"""Количество заказов в этом статусе"""
|
||||||
|
return self.orders.count()
|
||||||
@@ -62,7 +62,6 @@ class DraftOrderService:
|
|||||||
delivery_time_start=data.get('delivery_time_start'),
|
delivery_time_start=data.get('delivery_time_start'),
|
||||||
delivery_time_end=data.get('delivery_time_end'),
|
delivery_time_end=data.get('delivery_time_end'),
|
||||||
delivery_cost=data.get('delivery_cost', Decimal('0')),
|
delivery_cost=data.get('delivery_cost', Decimal('0')),
|
||||||
payment_method=data.get('payment_method', 'cash_to_courier'),
|
|
||||||
customer_is_recipient=data.get('customer_is_recipient', True),
|
customer_is_recipient=data.get('customer_is_recipient', True),
|
||||||
recipient_name=data.get('recipient_name'),
|
recipient_name=data.get('recipient_name'),
|
||||||
recipient_phone=data.get('recipient_phone'),
|
recipient_phone=data.get('recipient_phone'),
|
||||||
@@ -103,7 +102,7 @@ class DraftOrderService:
|
|||||||
|
|
||||||
simple_fields = [
|
simple_fields = [
|
||||||
'is_delivery', 'delivery_date', 'delivery_time_start', 'delivery_time_end',
|
'is_delivery', 'delivery_date', 'delivery_time_start', 'delivery_time_end',
|
||||||
'delivery_cost', 'payment_method', 'customer_is_recipient',
|
'delivery_cost', 'customer_is_recipient',
|
||||||
'recipient_name', 'recipient_phone', 'is_anonymous',
|
'recipient_name', 'recipient_phone', 'is_anonymous',
|
||||||
'special_instructions', 'discount_amount'
|
'special_instructions', 'discount_amount'
|
||||||
]
|
]
|
||||||
@@ -304,6 +303,71 @@ class DraftOrderService:
|
|||||||
is_custom_price=is_custom_price
|
is_custom_price=is_custom_price
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Обрабатываем удаление платежей
|
||||||
|
if 'deleted_payment_ids' in data:
|
||||||
|
deleted_payment_ids = data['deleted_payment_ids']
|
||||||
|
if deleted_payment_ids:
|
||||||
|
from ..models import Payment
|
||||||
|
Payment.objects.filter(id__in=deleted_payment_ids, order=order).delete()
|
||||||
|
|
||||||
|
# Обрабатываем платежи (payments)
|
||||||
|
if 'payments' in data:
|
||||||
|
from ..models import Payment, PaymentMethod
|
||||||
|
payments_data = data['payments']
|
||||||
|
|
||||||
|
# Обрабатываем каждый платеж
|
||||||
|
for payment_data in payments_data:
|
||||||
|
payment_id = payment_data.get('id') # ID существующего платежа (если есть)
|
||||||
|
payment_method_id = payment_data.get('payment_method_id')
|
||||||
|
amount_raw = payment_data.get('amount', '')
|
||||||
|
notes = payment_data.get('notes', '')
|
||||||
|
|
||||||
|
# Пропускаем пустые платежи
|
||||||
|
if not payment_method_id or not amount_raw:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Конвертируем сумму в Decimal
|
||||||
|
try:
|
||||||
|
amount = Decimal(str(amount_raw))
|
||||||
|
if amount <= 0:
|
||||||
|
continue
|
||||||
|
except (ValueError, TypeError, decimal.InvalidOperation):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Получаем способ оплаты
|
||||||
|
try:
|
||||||
|
payment_method = PaymentMethod.objects.get(pk=payment_method_id)
|
||||||
|
except PaymentMethod.DoesNotExist:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Обновляем существующий платеж или создаём новый
|
||||||
|
if payment_id:
|
||||||
|
# Обновляем существующий платеж
|
||||||
|
try:
|
||||||
|
payment = Payment.objects.get(id=payment_id, order=order)
|
||||||
|
payment.payment_method = payment_method
|
||||||
|
payment.amount = amount
|
||||||
|
payment.notes = notes
|
||||||
|
payment.save()
|
||||||
|
except Payment.DoesNotExist:
|
||||||
|
# Если платеж не найден, создаём новый
|
||||||
|
Payment.objects.create(
|
||||||
|
order=order,
|
||||||
|
payment_method=payment_method,
|
||||||
|
amount=amount,
|
||||||
|
notes=notes,
|
||||||
|
created_by=user
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Создаём новый платеж
|
||||||
|
Payment.objects.create(
|
||||||
|
order=order,
|
||||||
|
payment_method=payment_method,
|
||||||
|
amount=amount,
|
||||||
|
notes=notes,
|
||||||
|
created_by=user
|
||||||
|
)
|
||||||
|
|
||||||
order.modified_by = user
|
order.modified_by = user
|
||||||
order.last_autosave_at = timezone.now()
|
order.last_autosave_at = timezone.now()
|
||||||
order.save()
|
order.save()
|
||||||
|
|||||||
@@ -141,7 +141,6 @@
|
|||||||
'input[name="delivery_time_start"]',
|
'input[name="delivery_time_start"]',
|
||||||
'input[name="delivery_time_end"]',
|
'input[name="delivery_time_end"]',
|
||||||
'input[name="delivery_cost"]',
|
'input[name="delivery_cost"]',
|
||||||
'select[name="payment_method"]',
|
|
||||||
'textarea[name="special_instructions"]',
|
'textarea[name="special_instructions"]',
|
||||||
'input[name="discount_amount"]',
|
'input[name="discount_amount"]',
|
||||||
'input[type="checkbox"]',
|
'input[type="checkbox"]',
|
||||||
@@ -176,6 +175,9 @@
|
|||||||
|
|
||||||
// Слушаем изменения в формах товаров (formset)
|
// Слушаем изменения в формах товаров (formset)
|
||||||
observeFormsetChanges();
|
observeFormsetChanges();
|
||||||
|
|
||||||
|
// Слушаем изменения в формах платежей (payment formset)
|
||||||
|
observePaymentFormsetChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -232,6 +234,55 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Наблюдает за изменениями в формсете платежей
|
||||||
|
*/
|
||||||
|
function observePaymentFormsetChanges() {
|
||||||
|
const paymentsContainer = document.getElementById('payments-container');
|
||||||
|
if (!paymentsContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Наблюдаем за добавлением/удалением форм платежей
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
attachPaymentFormsetEventListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(paymentsContainer, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Прикрепляем обработчики к существующим формам
|
||||||
|
attachPaymentFormsetEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прикрепляет обработчики к полям в формах платежей
|
||||||
|
*/
|
||||||
|
function attachPaymentFormsetEventListeners() {
|
||||||
|
const paymentForms = document.querySelectorAll('.payment-form');
|
||||||
|
|
||||||
|
paymentForms.forEach(form => {
|
||||||
|
// Если уже прикреплены обработчики, пропускаем
|
||||||
|
if (form.dataset.autosavePaymentAttached === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = form.querySelectorAll('select, input[type="number"], input[type="text"], textarea, input[type="checkbox"]');
|
||||||
|
|
||||||
|
fields.forEach(field => {
|
||||||
|
if (field.tagName === 'SELECT' || field.type === 'checkbox') {
|
||||||
|
field.addEventListener('change', scheduleAutosave);
|
||||||
|
} else {
|
||||||
|
field.addEventListener('input', scheduleAutosave);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
form.dataset.autosavePaymentAttached = 'true';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Планирует автосохранение с задержкой (debouncing)
|
* Планирует автосохранение с задержкой (debouncing)
|
||||||
*/
|
*/
|
||||||
@@ -327,11 +378,6 @@
|
|||||||
data.delivery_cost = deliveryCostField.value;
|
data.delivery_cost = deliveryCostField.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentMethodField = form.querySelector('select[name="payment_method"]');
|
|
||||||
if (paymentMethodField && paymentMethodField.value) {
|
|
||||||
data.payment_method = paymentMethodField.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const specialInstructionsField = form.querySelector('textarea[name="special_instructions"]');
|
const specialInstructionsField = form.querySelector('textarea[name="special_instructions"]');
|
||||||
if (specialInstructionsField) {
|
if (specialInstructionsField) {
|
||||||
data.special_instructions = specialInstructionsField.value;
|
data.special_instructions = specialInstructionsField.value;
|
||||||
@@ -425,6 +471,11 @@
|
|||||||
data.items = orderItemsData.items;
|
data.items = orderItemsData.items;
|
||||||
data.deleted_item_ids = orderItemsData.deletedItemIds;
|
data.deleted_item_ids = orderItemsData.deletedItemIds;
|
||||||
|
|
||||||
|
// Собираем платежи
|
||||||
|
const paymentsData = collectPayments();
|
||||||
|
data.payments = paymentsData.payments;
|
||||||
|
data.deleted_payment_ids = paymentsData.deletedPaymentIds;
|
||||||
|
|
||||||
// Флаг для пересчета итоговой суммы
|
// Флаг для пересчета итоговой суммы
|
||||||
data.recalculate = true;
|
data.recalculate = true;
|
||||||
|
|
||||||
@@ -489,6 +540,53 @@
|
|||||||
return { items, deletedItemIds };
|
return { items, deletedItemIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собирает данные о платежах
|
||||||
|
*/
|
||||||
|
function collectPayments() {
|
||||||
|
const payments = [];
|
||||||
|
const deletedPaymentIds = [];
|
||||||
|
const paymentForms = document.querySelectorAll('.payment-form');
|
||||||
|
|
||||||
|
paymentForms.forEach(form => {
|
||||||
|
// Проверяем, помечена ли форма на удаление
|
||||||
|
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
|
||||||
|
const idField = form.querySelector('input[name$="-id"]');
|
||||||
|
|
||||||
|
if (deleteCheckbox && deleteCheckbox.checked) {
|
||||||
|
// Если форма помечена на удаление и имеет ID, добавляем в список удалённых
|
||||||
|
if (idField && idField.value) {
|
||||||
|
deletedPaymentIds.push(parseInt(idField.value));
|
||||||
|
}
|
||||||
|
return; // Не добавляем в payments
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем способ оплаты и сумму
|
||||||
|
const paymentMethodSelect = form.querySelector('select[name$="-payment_method"]');
|
||||||
|
const amountInput = form.querySelector('input[name$="-amount"]');
|
||||||
|
const notesInput = form.querySelector('textarea[name$="-notes"]');
|
||||||
|
|
||||||
|
if (!paymentMethodSelect || !paymentMethodSelect.value || !amountInput || !amountInput.value) {
|
||||||
|
return; // Пропускаем пустые платежи
|
||||||
|
}
|
||||||
|
|
||||||
|
const payment = {
|
||||||
|
payment_method_id: parseInt(paymentMethodSelect.value),
|
||||||
|
amount: (amountInput.value || '0').replace(',', '.'),
|
||||||
|
notes: notesInput ? notesInput.value : ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Если есть ID (существующий платеж), добавляем его
|
||||||
|
if (idField && idField.value) {
|
||||||
|
payment.id = parseInt(idField.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
payments.push(payment);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { payments, deletedPaymentIds };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получает CSRF токен из cookies или meta тега
|
* Получает CSRF токен из cookies или meta тега
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Визуально помечаем удаленные формы */
|
/* Визуально помечаем удаленные формы */
|
||||||
.order-item-form.deleted {
|
.order-item-form.deleted,
|
||||||
|
.payment-form.deleted {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -560,19 +561,110 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Оплата и дополнительно -->
|
<!-- Оплата (смешанная оплата) -->
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<h5 class="mb-0">Оплата</h5>
|
<h5 class="mb-0">Оплата</h5>
|
||||||
|
<button type="button" class="btn btn-sm btn-success" id="add-payment-btn">
|
||||||
|
<i class="bi bi-plus-circle"></i> Добавить платеж
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row">
|
<!-- Скрытые поля для formset management -->
|
||||||
<div class="col-md-4">
|
{{ payment_formset.management_form }}
|
||||||
<div class="mb-3">
|
|
||||||
<label for="{{ form.payment_method.id_for_label }}" class="form-label">Способ оплаты</label>
|
<!-- Контейнер для платежей -->
|
||||||
{{ form.payment_method }}
|
<div id="payments-container">
|
||||||
|
{% for payment_form in payment_formset %}
|
||||||
|
<div class="payment-form border rounded p-3 mb-3" data-form-index="{{ forloop.counter0 }}">
|
||||||
|
{{ payment_form.id }}
|
||||||
|
{{ payment_form.DELETE }}
|
||||||
|
|
||||||
|
<div class="row align-items-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Способ оплаты</label>
|
||||||
|
{{ payment_form.payment_method }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Сумма</label>
|
||||||
|
{{ payment_form.amount }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Примечания</label>
|
||||||
|
{{ payment_form.notes }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<button type="button" class="btn btn-danger btn-sm w-100 remove-payment-btn">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if payment_form.errors %}
|
||||||
|
<div class="alert alert-danger mt-2">{{ payment_form.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Итоговая сумма платежей -->
|
||||||
|
<div id="payments-total-section" class="border-top pt-3 mt-3 mb-3">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col">
|
||||||
|
<p class="mb-0 text-muted">Внесено платежей:</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<h5 class="mb-0 text-success">
|
||||||
|
<span id="payments-total-value">0.00</span> руб.
|
||||||
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Скрытый шаблон для новых платежей -->
|
||||||
|
<template id="empty-payment-form-template">
|
||||||
|
<div class="payment-form border rounded p-3 mb-3" data-form-index="__prefix__">
|
||||||
|
<input type="hidden" name="payments-__prefix__-id" id="id_payments-__prefix__-id">
|
||||||
|
<input type="checkbox" name="payments-__prefix__-DELETE" id="id_payments-__prefix__-DELETE" style="display: none;">
|
||||||
|
|
||||||
|
<div class="row align-items-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Способ оплаты</label>
|
||||||
|
<select name="payments-__prefix__-payment_method" class="form-select" id="id_payments-__prefix__-payment_method">
|
||||||
|
<option value="">---------</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Сумма</label>
|
||||||
|
<input type="number" name="payments-__prefix__-amount" step="0.01" min="0" class="form-control" placeholder="Сумма платежа" id="id_payments-__prefix__-amount">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Примечания</label>
|
||||||
|
<textarea name="payments-__prefix__-notes" class="form-control" rows="1" placeholder="Примечания к платежу (опционально)" id="id_payments-__prefix__-notes"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1">
|
||||||
|
<button type="button" class="btn btn-danger btn-sm w-100 remove-payment-btn">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Скидка -->
|
||||||
|
<div class="row mt-4">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="{{ form.discount_amount.id_for_label }}" class="form-label">Скидка</label>
|
<label for="{{ form.discount_amount.id_for_label }}" class="form-label">Скидка</label>
|
||||||
@@ -1457,6 +1549,141 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// === УПРАВЛЕНИЕ ПЛАТЕЖАМИ (СМЕШАННАЯ ОПЛАТА) ===
|
||||||
|
|
||||||
|
const paymentsContainer = document.getElementById('payments-container');
|
||||||
|
const addPaymentBtn = document.getElementById('add-payment-btn');
|
||||||
|
const paymentFormTemplate = document.getElementById('empty-payment-form-template');
|
||||||
|
let paymentFormCount = parseInt(document.querySelector('[name="payments-TOTAL_FORMS"]').value);
|
||||||
|
|
||||||
|
// Функция для расчета итоговой суммы платежей
|
||||||
|
function calculatePaymentsTotal() {
|
||||||
|
const visiblePaymentForms = Array.from(document.querySelectorAll('.payment-form'))
|
||||||
|
.filter(form => !form.classList.contains('deleted'));
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
visiblePaymentForms.forEach((form) => {
|
||||||
|
const amountField = form.querySelector('[name$="-amount"]');
|
||||||
|
if (amountField) {
|
||||||
|
const amount = parseFloat(amountField.value.replace(',', '.')) || 0;
|
||||||
|
total += amount;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePaymentsTotal() {
|
||||||
|
const total = calculatePaymentsTotal();
|
||||||
|
const totalElement = document.getElementById('payments-total-value');
|
||||||
|
|
||||||
|
if (totalElement) {
|
||||||
|
totalElement.textContent = total.toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для добавления нового платежа
|
||||||
|
function addNewPayment() {
|
||||||
|
const newPaymentHtml = paymentFormTemplate.content.cloneNode(true);
|
||||||
|
const newPaymentDiv = newPaymentHtml.querySelector('.payment-form');
|
||||||
|
|
||||||
|
// Заменяем __prefix__ на актуальный индекс
|
||||||
|
newPaymentDiv.innerHTML = newPaymentDiv.innerHTML.replace(/__prefix__/g, paymentFormCount);
|
||||||
|
newPaymentDiv.setAttribute('data-form-index', paymentFormCount);
|
||||||
|
|
||||||
|
// Добавляем в контейнер
|
||||||
|
paymentsContainer.appendChild(newPaymentDiv);
|
||||||
|
|
||||||
|
// Обновляем счетчик форм
|
||||||
|
paymentFormCount++;
|
||||||
|
document.querySelector('[name="payments-TOTAL_FORMS"]').value = paymentFormCount;
|
||||||
|
|
||||||
|
// Добавляем обработчик удаления
|
||||||
|
const removeBtn = newPaymentDiv.querySelector('.remove-payment-btn');
|
||||||
|
removeBtn.addEventListener('click', function() {
|
||||||
|
removePayment(newPaymentDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем обработчики для автоматического пересчета
|
||||||
|
const amountField = newPaymentDiv.querySelector('[name$="-amount"]');
|
||||||
|
if (amountField) {
|
||||||
|
amountField.addEventListener('input', updatePaymentsTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем payment methods в select
|
||||||
|
loadPaymentMethods(newPaymentDiv.querySelector('select[name$="-payment_method"]'));
|
||||||
|
|
||||||
|
// Обновляем итоговую сумму
|
||||||
|
updatePaymentsTotal();
|
||||||
|
|
||||||
|
return newPaymentDiv;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для удаления платежа
|
||||||
|
function removePayment(form) {
|
||||||
|
if (!confirm('Вы действительно хотите удалить этот платеж?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
|
||||||
|
const idField = form.querySelector('input[name$="-id"]');
|
||||||
|
|
||||||
|
// Если форма уже сохранена (есть ID), помечаем на удаление
|
||||||
|
if (idField && idField.value) {
|
||||||
|
deleteCheckbox.checked = true;
|
||||||
|
form.classList.add('deleted');
|
||||||
|
form.style.display = 'none';
|
||||||
|
console.log('Payment form marked for deletion, id:', idField.value);
|
||||||
|
} else {
|
||||||
|
// Если форма новая, просто удаляем из DOM
|
||||||
|
form.remove();
|
||||||
|
console.log('Payment form removed from DOM');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем итоговую сумму
|
||||||
|
updatePaymentsTotal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для загрузки активных payment methods
|
||||||
|
function loadPaymentMethods(selectElement) {
|
||||||
|
fetch('/products/api/payment-methods/')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
selectElement.innerHTML = '<option value="">---------</option>';
|
||||||
|
data.forEach(method => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = method.id;
|
||||||
|
option.textContent = method.name;
|
||||||
|
selectElement.appendChild(option);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error loading payment methods:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчик кнопки "Добавить платеж"
|
||||||
|
if (addPaymentBtn) {
|
||||||
|
addPaymentBtn.addEventListener('click', addNewPayment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем обработчики удаления для существующих платежей
|
||||||
|
paymentsContainer.querySelectorAll('.remove-payment-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
const form = this.closest('.payment-form');
|
||||||
|
removePayment(form);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем обработчики для автоматического пересчета для существующих форм
|
||||||
|
paymentsContainer.querySelectorAll('[name$="-amount"]').forEach(field => {
|
||||||
|
field.addEventListener('input', updatePaymentsTotal);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Инициализируем итоговую сумму при загрузке страницы
|
||||||
|
updatePaymentsTotal();
|
||||||
|
|
||||||
// Закрытие обработчика DOMContentLoaded для управления типом доставки и остальных функций
|
// Закрытие обработчика DOMContentLoaded для управления типом доставки и остальных функций
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ 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 .models import Order, OrderItem, Address, OrderStatus
|
from .models import Order, OrderItem, Address, OrderStatus
|
||||||
from .forms import OrderForm, OrderItemFormSet, OrderStatusForm
|
from .forms import OrderForm, OrderItemFormSet, OrderStatusForm, PaymentFormSet
|
||||||
from .filters import OrderFilter
|
from .filters import OrderFilter
|
||||||
from .services import DraftOrderService
|
from .services import DraftOrderService
|
||||||
from .services.address_service import AddressService
|
from .services.address_service import AddressService
|
||||||
@@ -65,8 +65,9 @@ def order_create(request):
|
|||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
form = OrderForm(request.POST)
|
form = OrderForm(request.POST)
|
||||||
formset = OrderItemFormSet(request.POST)
|
formset = OrderItemFormSet(request.POST)
|
||||||
|
payment_formset = PaymentFormSet(request.POST)
|
||||||
|
|
||||||
if form.is_valid() and formset.is_valid():
|
if form.is_valid() and formset.is_valid() and payment_formset.is_valid():
|
||||||
order = form.save(commit=False)
|
order = form.save(commit=False)
|
||||||
|
|
||||||
# Обрабатываем адрес доставки
|
# Обрабатываем адрес доставки
|
||||||
@@ -90,6 +91,10 @@ def order_create(request):
|
|||||||
formset.instance = order
|
formset.instance = order
|
||||||
formset.save()
|
formset.save()
|
||||||
|
|
||||||
|
# Сохраняем платежи
|
||||||
|
payment_formset.instance = order
|
||||||
|
payment_formset.save()
|
||||||
|
|
||||||
# Пересчитываем итоговую сумму
|
# Пересчитываем итоговую сумму
|
||||||
order.calculate_total()
|
order.calculate_total()
|
||||||
order.save()
|
order.save()
|
||||||
@@ -104,10 +109,12 @@ def order_create(request):
|
|||||||
else:
|
else:
|
||||||
form = OrderForm()
|
form = OrderForm()
|
||||||
formset = OrderItemFormSet()
|
formset = OrderItemFormSet()
|
||||||
|
payment_formset = PaymentFormSet()
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'form': form,
|
'form': form,
|
||||||
'formset': formset,
|
'formset': formset,
|
||||||
|
'payment_formset': payment_formset,
|
||||||
'title': 'Создание заказа',
|
'title': 'Создание заказа',
|
||||||
'button_text': 'Создать заказ',
|
'button_text': 'Создать заказ',
|
||||||
}
|
}
|
||||||
@@ -122,8 +129,9 @@ def order_update(request, pk):
|
|||||||
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)
|
||||||
|
payment_formset = PaymentFormSet(request.POST, instance=order)
|
||||||
|
|
||||||
if form.is_valid() and formset.is_valid():
|
if form.is_valid() and formset.is_valid() and payment_formset.is_valid():
|
||||||
order = form.save(commit=False)
|
order = form.save(commit=False)
|
||||||
|
|
||||||
# Если черновик финализируется
|
# Если черновик финализируется
|
||||||
@@ -136,6 +144,7 @@ def order_update(request, pk):
|
|||||||
messages.error(request, f'Ошибка финализации: {str(e)}')
|
messages.error(request, f'Ошибка финализации: {str(e)}')
|
||||||
form = OrderForm(instance=order)
|
form = OrderForm(instance=order)
|
||||||
formset = OrderItemFormSet(instance=order)
|
formset = OrderItemFormSet(instance=order)
|
||||||
|
payment_formset = PaymentFormSet(instance=order)
|
||||||
else:
|
else:
|
||||||
# Обрабатываем адрес доставки
|
# Обрабатываем адрес доставки
|
||||||
if order.is_delivery:
|
if order.is_delivery:
|
||||||
@@ -166,6 +175,9 @@ def order_update(request, pk):
|
|||||||
order.save()
|
order.save()
|
||||||
formset.save()
|
formset.save()
|
||||||
|
|
||||||
|
# Сохраняем платежи
|
||||||
|
payment_formset.save()
|
||||||
|
|
||||||
# Пересчитываем итоговую сумму
|
# Пересчитываем итоговую сумму
|
||||||
order.calculate_total()
|
order.calculate_total()
|
||||||
order.save()
|
order.save()
|
||||||
@@ -180,10 +192,12 @@ def order_update(request, pk):
|
|||||||
else:
|
else:
|
||||||
form = OrderForm(instance=order)
|
form = OrderForm(instance=order)
|
||||||
formset = OrderItemFormSet(instance=order)
|
formset = OrderItemFormSet(instance=order)
|
||||||
|
payment_formset = PaymentFormSet(instance=order)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'form': form,
|
'form': form,
|
||||||
'formset': formset,
|
'formset': formset,
|
||||||
|
'payment_formset': payment_formset,
|
||||||
'order': order,
|
'order': order,
|
||||||
'title': f'Редактирование {"черновика" if order.is_draft() else "заказа"} #{order.order_number}',
|
'title': f'Редактирование {"черновика" if order.is_draft() else "заказа"} #{order.order_number}',
|
||||||
'button_text': 'Сохранить изменения',
|
'button_text': 'Сохранить изменения',
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ urlpatterns = [
|
|||||||
path('api/categories/create/', api_views.create_category_api, name='api-category-create'),
|
path('api/categories/create/', api_views.create_category_api, name='api-category-create'),
|
||||||
path('api/categories/<int:pk>/rename/', api_views.rename_category_api, name='api-category-rename'),
|
path('api/categories/<int:pk>/rename/', api_views.rename_category_api, name='api-category-rename'),
|
||||||
path('api/products/<int:pk>/update-price/', api_views.update_product_price_api, name='api-update-product-price'),
|
path('api/products/<int:pk>/update-price/', api_views.update_product_price_api, name='api-update-product-price'),
|
||||||
|
path('api/payment-methods/', api_views.get_payment_methods, name='api-payment-methods'),
|
||||||
|
|
||||||
# Photo processing status API (for AJAX polling)
|
# Photo processing status API (for AJAX polling)
|
||||||
path('api/photos/status/<str:task_id>/', photo_status_api.photo_processing_status, name='api-photo-status'),
|
path('api/photos/status/<str:task_id>/', photo_status_api.photo_processing_status, name='api-photo-status'),
|
||||||
|
|||||||
@@ -1217,3 +1217,39 @@ def update_product_price_api(request, pk):
|
|||||||
'success': False,
|
'success': False,
|
||||||
'error': f'Ошибка при обновлении цены: {str(e)}'
|
'error': f'Ошибка при обновлении цены: {str(e)}'
|
||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
def get_payment_methods(request):
|
||||||
|
"""
|
||||||
|
API endpoint для получения списка активных способов оплаты.
|
||||||
|
Используется для динамической загрузки payment methods в JavaScript.
|
||||||
|
|
||||||
|
Возвращает JSON:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Наличные курьеру",
|
||||||
|
"code": "cash_to_courier",
|
||||||
|
"description": "Оплата наличными при получении заказа"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from orders.models import PaymentMethod
|
||||||
|
|
||||||
|
# Получаем все активные способы оплаты, упорядоченные по полю order и названию
|
||||||
|
payment_methods = PaymentMethod.objects.filter(
|
||||||
|
is_active=True
|
||||||
|
).order_by('order', 'name').values('id', 'name', 'code', 'description')
|
||||||
|
|
||||||
|
# Преобразуем QuerySet в список
|
||||||
|
methods_list = list(payment_methods)
|
||||||
|
|
||||||
|
return JsonResponse(methods_list, safe=False)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Ошибка при загрузке способов оплаты: {str(e)}')
|
||||||
|
return JsonResponse({
|
||||||
|
'error': f'Ошибка при загрузке способов оплаты: {str(e)}'
|
||||||
|
}, status=500)
|
||||||
|
|||||||
@@ -310,6 +310,57 @@ class TenantRegistrationAdmin(admin.ModelAdmin):
|
|||||||
logger.error(f"Ошибка при создании статусов заказов: {e}", exc_info=True)
|
logger.error(f"Ошибка при создании статусов заказов: {e}", exc_info=True)
|
||||||
# Не прерываем процесс, т.к. это не критично
|
# Не прерываем процесс, т.к. это не критично
|
||||||
|
|
||||||
|
# Создаем системные способы оплаты
|
||||||
|
logger.info(f"Создание системных способов оплаты для тенанта: {client.id}")
|
||||||
|
from orders.models import PaymentMethod
|
||||||
|
|
||||||
|
try:
|
||||||
|
payment_methods = [
|
||||||
|
{
|
||||||
|
'code': 'cash',
|
||||||
|
'name': 'Наличными',
|
||||||
|
'description': 'Оплата наличными деньгами',
|
||||||
|
'is_system': True,
|
||||||
|
'order': 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'card',
|
||||||
|
'name': 'Картой',
|
||||||
|
'description': 'Оплата банковской картой',
|
||||||
|
'is_system': True,
|
||||||
|
'order': 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'online',
|
||||||
|
'name': 'Онлайн',
|
||||||
|
'description': 'Онлайн оплата через платежную систему',
|
||||||
|
'is_system': True,
|
||||||
|
'order': 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'legal_entity',
|
||||||
|
'name': 'Безнал от ЮРЛИЦ',
|
||||||
|
'description': 'Безналичный расчёт от юридических лиц',
|
||||||
|
'is_system': True,
|
||||||
|
'order': 4
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
for method_data in payment_methods:
|
||||||
|
method, created = PaymentMethod.objects.get_or_create(
|
||||||
|
code=method_data['code'],
|
||||||
|
defaults=method_data
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
created_count += 1
|
||||||
|
logger.info(f"Создан способ оплаты: {method.name}")
|
||||||
|
|
||||||
|
logger.info(f"Системные способы оплаты успешно созданы: {created_count} новых")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при создании способов оплаты: {e}", exc_info=True)
|
||||||
|
# Не прерываем процесс, т.к. это не критично
|
||||||
|
|
||||||
# Возвращаемся в public схему
|
# Возвращаемся в public схему
|
||||||
connection.set_schema_to_public()
|
connection.set_schema_to_public()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user