From 0653ec05458a76c24894a7df2c0e50ba7cfd9648 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Wed, 26 Nov 2025 13:38:02 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=B7=D0=B0=D0=BA=D0=B0=D0=B7=D0=BE=D0=B2=20=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=D0=BE=D0=B2=20=D0=BE=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- myproject/orders/admin.py | 78 +- myproject/orders/forms.py | 53 +- myproject/orders/management/__init__.py | 1 - .../orders/management/commands/__init__.py | 1 - .../commands/create_payment_methods.py | 59 ++ ..._refactor_models_and_add_payment_method.py | 61 ++ myproject/orders/models.py | 848 ------------------ myproject/orders/models/__init__.py | 35 + myproject/orders/models/address.py | 142 +++ myproject/orders/models/order.py | 388 ++++++++ myproject/orders/models/order_item.py | 154 ++++ myproject/orders/models/payment.py | 144 +++ myproject/orders/models/status.py | 100 +++ myproject/orders/services/draft_service.py | 68 +- myproject/orders/static/orders/js/autosave.js | 110 ++- .../orders/templates/orders/order_form.html | 243 ++++- myproject/orders/views.py | 20 +- myproject/products/urls.py | 1 + myproject/products/views/api_views.py | 36 + myproject/tenants/admin.py | 51 ++ 20 files changed, 1720 insertions(+), 873 deletions(-) create mode 100644 myproject/orders/management/commands/create_payment_methods.py create mode 100644 myproject/orders/migrations/0004_refactor_models_and_add_payment_method.py delete mode 100644 myproject/orders/models.py create mode 100644 myproject/orders/models/__init__.py create mode 100644 myproject/orders/models/address.py create mode 100644 myproject/orders/models/order.py create mode 100644 myproject/orders/models/order_item.py create mode 100644 myproject/orders/models/payment.py create mode 100644 myproject/orders/models/status.py diff --git a/myproject/orders/admin.py b/myproject/orders/admin.py index d4d6720..766adfa 100644 --- a/myproject/orders/admin.py +++ b/myproject/orders/admin.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from django.contrib import admin 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): @@ -94,7 +94,6 @@ class OrderAdmin(admin.ModelAdmin): }), ('Оплата', { 'fields': ( - 'payment_method', 'total_amount', 'discount_amount', 'amount_paid', @@ -376,3 +375,78 @@ class OrderStatusAdmin(admin.ModelAdmin): if obj.is_system or obj.orders_count > 0: return False 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( + '{}', + obj.order + ) + order_display.short_description = 'Порядок' + + def payments_count(self, obj): + """Количество платежей этим способом""" + count = obj.payments.count() + if count == 0: + return format_html('{}', count) + return format_html('{}', 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) diff --git a/myproject/orders/forms.py b/myproject/orders/forms.py index 272cd31..102a4fb 100644 --- a/myproject/orders/forms.py +++ b/myproject/orders/forms.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from django import forms 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 inventory.models import Warehouse from products.models import Product, ProductKit @@ -101,7 +101,6 @@ class OrderForm(forms.ModelForm): 'recipient_name', 'recipient_phone', 'status', - 'payment_method', 'discount_amount', 'is_anonymous', 'special_instructions', @@ -443,3 +442,53 @@ TemporaryKitItemFormSet = formset_factory( min_num=1, # Минимум 1 компонент в комплекте 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, +) diff --git a/myproject/orders/management/__init__.py b/myproject/orders/management/__init__.py index e5999a7..e69de29 100644 --- a/myproject/orders/management/__init__.py +++ b/myproject/orders/management/__init__.py @@ -1 +0,0 @@ -# Management commands for orders app diff --git a/myproject/orders/management/commands/__init__.py b/myproject/orders/management/commands/__init__.py index 2c1c7c1..e69de29 100644 --- a/myproject/orders/management/commands/__init__.py +++ b/myproject/orders/management/commands/__init__.py @@ -1 +0,0 @@ -# Management commands diff --git a/myproject/orders/management/commands/create_payment_methods.py b/myproject/orders/management/commands/create_payment_methods.py new file mode 100644 index 0000000..723677d --- /dev/null +++ b/myproject/orders/management/commands/create_payment_methods.py @@ -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} новых способов оплаты.') + ) diff --git a/myproject/orders/migrations/0004_refactor_models_and_add_payment_method.py b/myproject/orders/migrations/0004_refactor_models_and_add_payment_method.py new file mode 100644 index 0000000..524f9eb --- /dev/null +++ b/myproject/orders/migrations/0004_refactor_models_and_add_payment_method.py @@ -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'), + ), + ] diff --git a/myproject/orders/models.py b/myproject/orders/models.py deleted file mode 100644 index dd5b02b..0000000 --- a/myproject/orders/models.py +++ /dev/null @@ -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() diff --git a/myproject/orders/models/__init__.py b/myproject/orders/models/__init__.py new file mode 100644 index 0000000..ce9d131 --- /dev/null +++ b/myproject/orders/models/__init__.py @@ -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', +] diff --git a/myproject/orders/models/address.py b/myproject/orders/models/address.py new file mode 100644 index 0000000..c5a17a9 --- /dev/null +++ b/myproject/orders/models/address.py @@ -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 diff --git a/myproject/orders/models/order.py b/myproject/orders/models/order.py new file mode 100644 index 0000000..8636a4e --- /dev/null +++ b/myproject/orders/models/order.py @@ -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 "Время не указано" diff --git a/myproject/orders/models/order_item.py b/myproject/orders/models/order_item.py new file mode 100644 index 0000000..e2782d0 --- /dev/null +++ b/myproject/orders/models/order_item.py @@ -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 diff --git a/myproject/orders/models/payment.py b/myproject/orders/models/payment.py new file mode 100644 index 0000000..03a0635 --- /dev/null +++ b/myproject/orders/models/payment.py @@ -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() diff --git a/myproject/orders/models/status.py b/myproject/orders/models/status.py new file mode 100644 index 0000000..318ccd0 --- /dev/null +++ b/myproject/orders/models/status.py @@ -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() diff --git a/myproject/orders/services/draft_service.py b/myproject/orders/services/draft_service.py index 5d44958..28547e2 100644 --- a/myproject/orders/services/draft_service.py +++ b/myproject/orders/services/draft_service.py @@ -62,7 +62,6 @@ class DraftOrderService: delivery_time_start=data.get('delivery_time_start'), delivery_time_end=data.get('delivery_time_end'), 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), recipient_name=data.get('recipient_name'), recipient_phone=data.get('recipient_phone'), @@ -103,7 +102,7 @@ class DraftOrderService: simple_fields = [ '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', 'special_instructions', 'discount_amount' ] @@ -304,6 +303,71 @@ class DraftOrderService: 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.last_autosave_at = timezone.now() order.save() diff --git a/myproject/orders/static/orders/js/autosave.js b/myproject/orders/static/orders/js/autosave.js index f1d918d..ed1d554 100644 --- a/myproject/orders/static/orders/js/autosave.js +++ b/myproject/orders/static/orders/js/autosave.js @@ -141,7 +141,6 @@ 'input[name="delivery_time_start"]', 'input[name="delivery_time_end"]', 'input[name="delivery_cost"]', - 'select[name="payment_method"]', 'textarea[name="special_instructions"]', 'input[name="discount_amount"]', 'input[type="checkbox"]', @@ -176,6 +175,9 @@ // Слушаем изменения в формах товаров (formset) 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) */ @@ -327,11 +378,6 @@ 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"]'); if (specialInstructionsField) { data.special_instructions = specialInstructionsField.value; @@ -425,6 +471,11 @@ data.items = orderItemsData.items; data.deleted_item_ids = orderItemsData.deletedItemIds; + // Собираем платежи + const paymentsData = collectPayments(); + data.payments = paymentsData.payments; + data.deleted_payment_ids = paymentsData.deletedPaymentIds; + // Флаг для пересчета итоговой суммы data.recalculate = true; @@ -489,6 +540,53 @@ 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 тега */ diff --git a/myproject/orders/templates/orders/order_form.html b/myproject/orders/templates/orders/order_form.html index 3f72a70..142c1f8 100644 --- a/myproject/orders/templates/orders/order_form.html +++ b/myproject/orders/templates/orders/order_form.html @@ -12,7 +12,8 @@ } /* Визуально помечаем удаленные формы */ - .order-item-form.deleted { + .order-item-form.deleted, + .payment-form.deleted { opacity: 0.5; pointer-events: none; } @@ -560,19 +561,110 @@ - +
-
+
Оплата
+
-
-
-
- - {{ form.payment_method }} + + {{ payment_formset.management_form }} + + +
+ {% for payment_form in payment_formset %} +
+ {{ payment_form.id }} + {{ payment_form.DELETE }} + +
+
+
+ + {{ payment_form.payment_method }} +
+
+
+
+ + {{ payment_form.amount }} +
+
+
+
+ + {{ payment_form.notes }} +
+
+
+ +
+
+ + {% if payment_form.errors %} +
{{ payment_form.errors }}
+ {% endif %} +
+ {% endfor %} +
+ + +
+
+
+

Внесено платежей:

+
+
+
+ 0.00 руб. +
+
+ + + + + +
@@ -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 = ''; + 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 для управления типом доставки и остальных функций }); diff --git a/myproject/orders/views.py b/myproject/orders/views.py index bccb9e6..2cb2954 100644 --- a/myproject/orders/views.py +++ b/myproject/orders/views.py @@ -8,7 +8,7 @@ from django.contrib.auth.decorators import login_required from django.core.exceptions import ValidationError from django.db import models 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 .services import DraftOrderService from .services.address_service import AddressService @@ -65,8 +65,9 @@ def order_create(request): if request.method == 'POST': form = OrderForm(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) # Обрабатываем адрес доставки @@ -90,6 +91,10 @@ def order_create(request): formset.instance = order formset.save() + # Сохраняем платежи + payment_formset.instance = order + payment_formset.save() + # Пересчитываем итоговую сумму order.calculate_total() order.save() @@ -104,10 +109,12 @@ def order_create(request): else: form = OrderForm() formset = OrderItemFormSet() + payment_formset = PaymentFormSet() context = { 'form': form, 'formset': formset, + 'payment_formset': payment_formset, 'title': 'Создание заказа', 'button_text': 'Создать заказ', } @@ -122,8 +129,9 @@ def order_update(request, pk): if request.method == 'POST': form = OrderForm(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) # Если черновик финализируется @@ -136,6 +144,7 @@ def order_update(request, pk): messages.error(request, f'Ошибка финализации: {str(e)}') form = OrderForm(instance=order) formset = OrderItemFormSet(instance=order) + payment_formset = PaymentFormSet(instance=order) else: # Обрабатываем адрес доставки if order.is_delivery: @@ -166,6 +175,9 @@ def order_update(request, pk): order.save() formset.save() + # Сохраняем платежи + payment_formset.save() + # Пересчитываем итоговую сумму order.calculate_total() order.save() @@ -180,10 +192,12 @@ def order_update(request, pk): else: form = OrderForm(instance=order) formset = OrderItemFormSet(instance=order) + payment_formset = PaymentFormSet(instance=order) context = { 'form': form, 'formset': formset, + 'payment_formset': payment_formset, 'order': order, 'title': f'Редактирование {"черновика" if order.is_draft() else "заказа"} #{order.order_number}', 'button_text': 'Сохранить изменения', diff --git a/myproject/products/urls.py b/myproject/products/urls.py index b6fb912..c2a3c2f 100644 --- a/myproject/products/urls.py +++ b/myproject/products/urls.py @@ -50,6 +50,7 @@ urlpatterns = [ path('api/categories/create/', api_views.create_category_api, name='api-category-create'), path('api/categories//rename/', api_views.rename_category_api, name='api-category-rename'), path('api/products//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) path('api/photos/status//', photo_status_api.photo_processing_status, name='api-photo-status'), diff --git a/myproject/products/views/api_views.py b/myproject/products/views/api_views.py index f693bc1..656fb41 100644 --- a/myproject/products/views/api_views.py +++ b/myproject/products/views/api_views.py @@ -1217,3 +1217,39 @@ def update_product_price_api(request, pk): 'success': False, 'error': f'Ошибка при обновлении цены: {str(e)}' }, 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) diff --git a/myproject/tenants/admin.py b/myproject/tenants/admin.py index 6685a9c..16ce529 100644 --- a/myproject/tenants/admin.py +++ b/myproject/tenants/admin.py @@ -310,6 +310,57 @@ class TenantRegistrationAdmin(admin.ModelAdmin): 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 схему connection.set_schema_to_public()