diff --git a/myproject/orders/forms.py b/myproject/orders/forms.py new file mode 100644 index 0000000..ba8650c --- /dev/null +++ b/myproject/orders/forms.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +from django import forms +from django.forms import inlineformset_factory +from .models import Order, OrderItem +from customers.models import Customer, Address +from shops.models import Shop +from products.models import Product, ProductKit + + +class OrderForm(forms.ModelForm): + """Форма для создания и редактирования заказа""" + + class Meta: + model = Order + fields = [ + 'customer', + 'is_delivery', + 'delivery_address', + 'pickup_shop', + 'delivery_date', + 'delivery_time_start', + 'delivery_time_end', + 'delivery_cost', + 'customer_is_recipient', + 'recipient_name', + 'recipient_phone', + 'status', + 'payment_method', + 'discount_amount', + 'is_anonymous', + 'special_instructions', + ] + widgets = { + 'delivery_date': forms.DateInput(attrs={'type': 'date'}), + 'delivery_time_start': forms.TimeInput(attrs={'type': 'time'}), + 'delivery_time_end': forms.TimeInput(attrs={'type': 'time'}), + 'special_instructions': forms.Textarea(attrs={'rows': 3}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Добавляем Bootstrap классы ко всем полям + for field_name, field in self.fields.items(): + if isinstance(field.widget, forms.CheckboxInput): + field.widget.attrs.update({'class': 'form-check-input'}) + elif isinstance(field.widget, forms.Textarea): + field.widget.attrs.update({'class': 'form-control', 'rows': 3}) + else: + field.widget.attrs.update({'class': 'form-control'}) + + # Select2 для выпадающих списков + self.fields['customer'].widget.attrs.update({ + 'class': 'form-select select2', + 'data-placeholder': 'Выберите клиента' + }) + + self.fields['delivery_address'].widget.attrs.update({ + 'class': 'form-select select2', + 'data-placeholder': 'Выберите адрес доставки' + }) + self.fields['delivery_address'].required = False + + self.fields['pickup_shop'].widget.attrs.update({ + 'class': 'form-select select2', + 'data-placeholder': 'Выберите точку самовывоза' + }) + self.fields['pickup_shop'].required = False + + # Опциональные поля даты/времени + self.fields['delivery_date'].required = False + self.fields['delivery_time_start'].required = False + self.fields['delivery_time_end'].required = False + + # Подсказки + self.fields['is_delivery'].label = 'С доставкой' + self.fields['customer_is_recipient'].label = 'Покупатель = получатель' + + # Поля получателя опциональны + self.fields['recipient_name'].required = False + self.fields['recipient_phone'].required = False + + +class OrderItemForm(forms.ModelForm): + """Форма для позиции заказа""" + + class Meta: + model = OrderItem + fields = ['product', 'product_kit', 'quantity', 'price', 'is_custom_price'] + widgets = { + 'quantity': forms.NumberInput(attrs={'min': 1, 'value': 1}), + # Скрываем поля product и product_kit - они будут заполняться через JS + 'product': forms.HiddenInput(), + 'product_kit': forms.HiddenInput(), + 'is_custom_price': forms.HiddenInput(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Bootstrap классы + for field_name, field in self.fields.items(): + if not isinstance(field.widget, forms.HiddenInput): + field.widget.attrs.update({'class': 'form-control'}) + + # Поля product и product_kit опциональны + self.fields['product'].required = False + self.fields['product_kit'].required = False + + # Поле цены заполняется автоматически, но можно редактировать вручную + self.fields['price'].widget.attrs.update({ + 'placeholder': 'Цена', + 'step': '0.01' + }) + self.fields['price'].required = False + + # Поле is_custom_price устанавливается через JS + self.fields['is_custom_price'].required = False + + def clean(self): + """Валидация: должен быть выбран либо товар, либо комплект (не оба, не ни один)""" + cleaned_data = super().clean() + product = cleaned_data.get('product') + product_kit = cleaned_data.get('product_kit') + quantity = cleaned_data.get('quantity') + + # Пустая форма - это нормально (будет удалена) + if not product and not product_kit: + # Обнуляем количество для пустых форм + cleaned_data['quantity'] = None + return cleaned_data + + # Проверка: нельзя выбрать оба одновременно + if product and product_kit: + raise forms.ValidationError( + 'Нельзя указывать одновременно товар и комплект. Выберите что-то одно.' + ) + + # Проверка: если выбрано что-то, количество обязательно + if (product or product_kit): + if not quantity or quantity <= 0: + raise forms.ValidationError('Необходимо указать количество больше 0') + + return cleaned_data + + +# Formset для inline добавления товаров в заказ +OrderItemFormSet = inlineformset_factory( + Order, + OrderItem, + form=OrderItemForm, + extra=1, # Одна пустая форма для добавления + can_delete=True, + min_num=1, # Минимум 1 товар в заказе + validate_min=True, +) diff --git a/myproject/orders/migrations/0004_orderitem_is_custom_price.py b/myproject/orders/migrations/0004_orderitem_is_custom_price.py new file mode 100644 index 0000000..ae78b9e --- /dev/null +++ b/myproject/orders/migrations/0004_orderitem_is_custom_price.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.10 on 2025-11-07 06:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0003_historicalorder_recipient_name_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='orderitem', + name='is_custom_price', + field=models.BooleanField(default=False, help_text='True если цена была изменена вручную при создании заказа', verbose_name='Цена изменена вручную'), + ), + ] diff --git a/myproject/orders/models.py b/myproject/orders/models.py index fe51587..6a8a106 100644 --- a/myproject/orders/models.py +++ b/myproject/orders/models.py @@ -4,6 +4,7 @@ from accounts.models import CustomUser from customers.models import Customer, Address from products.models import Product, ProductKit from shops.models import Shop +from simple_history.models import HistoricalRecords import uuid @@ -28,16 +29,10 @@ class Order(models.Model): ) # Тип доставки - DELIVERY_TYPE_CHOICES = [ - ('courier', 'Курьерская доставка'), - ('pickup', 'Самовывоз'), - ] - - delivery_type = models.CharField( - max_length=20, - choices=DELIVERY_TYPE_CHOICES, - default='courier', - verbose_name="Тип доставки" + is_delivery = models.BooleanField( + default=True, + verbose_name="С доставкой", + help_text="True - доставка курьером, False - самовывоз" ) # Адрес доставки (для курьерской доставки) @@ -64,15 +59,22 @@ class Order(models.Model): # Дата и время доставки/самовывоза delivery_date = models.DateField( - verbose_name="Дата доставки/самовывоза" + 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="Конец временного интервала" ) @@ -130,7 +132,62 @@ class Order(models.Model): 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="Анонимная доставка", @@ -155,6 +212,19 @@ class Order(models.Model): 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 = "Заказы" @@ -162,7 +232,8 @@ class Order(models.Model): models.Index(fields=['customer']), models.Index(fields=['status']), models.Index(fields=['delivery_date']), - models.Index(fields=['delivery_type']), + models.Index(fields=['is_delivery']), + models.Index(fields=['payment_status']), models.Index(fields=['created_at']), models.Index(fields=['order_number']), ] @@ -189,14 +260,14 @@ class Order(models.Model): """Валидация модели""" super().clean() - # Проверка: для курьерской доставки обязателен адрес - if self.delivery_type == 'courier' and not self.delivery_address: + # Проверка: для доставки обязателен адрес + if self.is_delivery and not self.delivery_address: raise ValidationError({ - 'delivery_address': 'Для курьерской доставки необходимо указать адрес доставки' + 'delivery_address': 'Для доставки необходимо указать адрес доставки' }) # Проверка: для самовывоза обязателен пункт самовывоза - if self.delivery_type == 'pickup' and not self.pickup_shop: + if not self.is_delivery and not self.pickup_shop: raise ValidationError({ 'pickup_shop': 'Для самовывоза необходимо выбрать точку самовывоза' }) @@ -211,22 +282,46 @@ class Order(models.Model): def calculate_total(self): """Рассчитывает итоговую сумму заказа""" items_total = sum(item.get_total_price() for item in self.items.all()) - self.total_amount = items_total + self.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() + + @property + def amount_due(self): + """Остаток к оплате""" + return max(self.total_amount - self.amount_paid, 0) + @property def delivery_info(self): """Информация о доставке для отображения""" - if self.delivery_type == 'courier': - return f"Доставка по адресу: {self.delivery_address.full_address}" - elif self.delivery_type == 'pickup': - return f"Самовывоз из: {self.pickup_shop.name}" - return "Не указано" + if self.is_delivery: + if self.delivery_address: + return f"Доставка по адресу: {self.delivery_address.full_address}" + return "Доставка (адрес не указан)" + else: + if self.pickup_shop: + return f"Самовывоз из: {self.pickup_shop.name}" + return "Самовывоз (точка не указана)" @property def delivery_time_window(self): """Временное окно доставки""" - return f"{self.delivery_time_start.strftime('%H:%M')} - {self.delivery_time_end.strftime('%H:%M')}" + 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): @@ -272,6 +367,12 @@ class OrderItem(models.Model): help_text="Цена на момент создания заказа (фиксируется)" ) + is_custom_price = models.BooleanField( + default=False, + verbose_name="Цена изменена вручную", + help_text="True если цена была изменена вручную при создании заказа" + ) + # Временные метки created_at = models.DateTimeField( auto_now_add=True, @@ -332,3 +433,84 @@ class OrderItem(models.Model): 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/templates/orders/order_detail.html b/myproject/orders/templates/orders/order_detail.html new file mode 100644 index 0000000..7a35d6f --- /dev/null +++ b/myproject/orders/templates/orders/order_detail.html @@ -0,0 +1,319 @@ +{% extends 'base.html' %} + +{% block title %}Заказ {{ order.order_number }}{% endblock %} + +{% block content %} +
| Наименование | +Количество | +Цена | +Сумма | +
|---|---|---|---|
| {{ item.item_name }} | +{{ item.quantity }} | +
+ {{ item.price }} руб.
+ {% if item.is_custom_price %}
+ Изменена
+ + + Оригинальная: {{ item.original_price }} руб. + {% if item.price_difference %} + {% if item.price_difference > 0 %} + (+{{ item.price_difference }} руб.) + {% else %} + ({{ item.price_difference }} руб.) + {% endif %} + {% endif %} + + {% endif %} + |
+ {{ item.get_total_price }} руб. | +