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 %} +
+
+
+

Заказ {{ order.order_number }}

+
+ +
+ +
+ +
+ +
+
+
Информация о заказе
+
+
+
+
Клиент:
+ +
+
+
Телефон:
+
{{ order.customer.phone }}
+
+
+
Статус:
+
+ {% if order.status == 'new' %} + Новый + {% elif order.status == 'confirmed' %} + Подтвержден + {% elif order.status == 'in_assembly' %} + В сборке + {% elif order.status == 'in_delivery' %} + В доставке + {% elif order.status == 'delivered' %} + Доставлен + {% elif order.status == 'cancelled' %} + Отменен + {% endif %} +
+
+
+
Создан:
+
{{ order.created_at|date:"d.m.Y H:i" }}
+
+
+
Обновлен:
+
{{ order.updated_at|date:"d.m.Y H:i" }}
+
+
+
+ + + {% if not order.customer_is_recipient %} +
+
+
Получатель
+
+
+
+
Имя получателя:
+
{{ order.recipient_name|default:"Не указано" }}
+
+
+
Телефон получателя:
+
{{ order.recipient_phone|default:"Не указан" }}
+
+ {% if order.is_anonymous %} +
+
+ Анонимная доставка +
+
+ {% endif %} +
+
+ {% endif %} + + +
+
+
Доставка
+
+
+
+
Тип:
+
+ {% if order.is_delivery %} + Доставка курьером + {% else %} + Самовывоз + {% endif %} +
+
+ {% if order.is_delivery %} +
+
Адрес:
+
+ {% if order.delivery_address %} + {{ order.delivery_address.full_address }} + {% else %} + Не указан + {% endif %} +
+
+
+
Стоимость доставки:
+
{{ order.delivery_cost }} руб.
+
+ {% else %} +
+
Точка самовывоза:
+
+ {% if order.pickup_shop %} + {{ order.pickup_shop.name }}
+ {{ order.pickup_shop.address }} + {% else %} + Не указана + {% endif %} +
+
+ {% endif %} +
+
Дата:
+
+ {% if order.delivery_date %} + {{ order.delivery_date|date:"d.m.Y" }} + {% else %} + Не указана + {% endif %} +
+
+
+
Время:
+
+ {% if order.delivery_time_start and order.delivery_time_end %} + {{ order.delivery_time_window }} + {% else %} + Не указано + {% endif %} +
+
+ {% if order.special_instructions %} +
+
Особые пожелания:
+
{{ order.special_instructions }}
+
+ {% endif %} + {% if order.is_anonymous %} +
+
+ Анонимная доставка +
+
+ {% endif %} +
+
+ + +
+
+
Товары в заказе
+
+
+ + + + + + + + + + + {% for item in order.items.all %} + + + + + + + {% endfor %} + +
НаименованиеКоличествоЦенаСумма
{{ 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 }} руб.
+
+
+
+ + +
+ +
+
+
Оплата
+
+
+
+
Товары:
+
+ {% with items_total=order.items.all|length %} + {% if items_total > 0 %} + {{ order.total_amount|floatformat:2 }} руб. + {% else %} + 0.00 руб. + {% endif %} + {% endwith %} +
+
+ {% if order.is_delivery %} +
+
Доставка:
+
{{ order.delivery_cost }} руб.
+
+ {% endif %} + {% if order.discount_amount > 0 %} +
+
Скидка:
+
-{{ order.discount_amount }} руб.
+
+ {% endif %} +
+
+
Итого:
+
{{ order.total_amount }} руб.
+
+
+
Оплачено:
+
{{ order.amount_paid }} руб.
+
+
+
К оплате:
+
{{ order.amount_due }} руб.
+
+
+
+ Статус оплаты: + {% if order.payment_status == 'paid' %} + Оплачен полностью + {% elif order.payment_status == 'partial' %} + Частично оплачен + {% else %} + Не оплачен + {% endif %} +
+
+
+
+ Способ оплаты:
+ {{ order.get_payment_method_display }} +
+
+
+
+ + + {% if order.payments.all %} +
+
+
История платежей
+
+
+
    + {% for payment in order.payments.all %} +
  • +
    {{ payment.amount }} руб.
    + + {{ payment.payment_date|date:"d.m.Y H:i" }}
    + {{ payment.get_payment_method_display }} + {% if payment.created_by %} +
    Принял: {{ payment.created_by.get_full_name }} + {% endif %} +
    +
  • + {% endfor %} +
+
+
+ {% endif %} +
+
+
+{% endblock %} diff --git a/myproject/orders/templates/orders/order_form.html b/myproject/orders/templates/orders/order_form.html new file mode 100644 index 0000000..cc3a02b --- /dev/null +++ b/myproject/orders/templates/orders/order_form.html @@ -0,0 +1,536 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+
+
+

{{ title }}

+
+ +
+ +
+ {% csrf_token %} + + +
+
+
Основная информация
+
+
+
+
+
+ + {{ form.customer }} + {% if form.customer.errors %} +
{{ form.customer.errors }}
+ {% endif %} +
+
+
+
+ + {{ form.status }} + {% if form.status.errors %} +
{{ form.status.errors }}
+ {% endif %} +
+
+
+
+
+ + +
+
+
Доставка
+
+
+
+
+
+ {{ form.is_delivery }} + +
+
+ {{ form.customer_is_recipient }} + +
+
+
+ + + + +
+
+
+ + {{ form.delivery_address }} + {% if form.delivery_address.errors %} +
{{ form.delivery_address.errors }}
+ {% endif %} +
+
+
+
+ + {{ form.delivery_cost }} + {% if form.delivery_cost.errors %} +
{{ form.delivery_cost.errors }}
+ {% endif %} +
+
+
+ + + +
+
+
+ + {{ form.delivery_date }} + {% if form.delivery_date.errors %} +
{{ form.delivery_date.errors }}
+ {% endif %} +
+
+
+
+ + {{ form.delivery_time_start }} + {% if form.delivery_time_start.errors %} +
{{ form.delivery_time_start.errors }}
+ {% endif %} +
+
+
+
+ + {{ form.delivery_time_end }} + {% if form.delivery_time_end.errors %} +
{{ form.delivery_time_end.errors }}
+ {% endif %} +
+
+
+
+
+ + +
+
+
Товары в заказе
+
+
+ {{ formset.management_form }} +
+ {% for item_form in formset %} +
+ {{ item_form.id }} + {{ item_form.product }} + {{ item_form.product_kit }} + {{ item_form.is_custom_price }} + +
+
+
+ + +
+
+
+
+ + {{ item_form.quantity }} +
+
+
+
+ +
+ {{ item_form.price }} + + +
+
+
+
+ {% if formset.can_delete %} +
+
+ {{ item_form.DELETE }} + +
+
+ {% endif %} +
+
+ {% if item_form.errors %} +
{{ item_form.errors }}
+ {% endif %} +
+ {% endfor %} +
+ +
+
+ + +
+
+
Оплата
+
+
+
+
+
+ + {{ form.payment_method }} +
+
+
+
+ + {{ form.discount_amount }} +
+
+
+
+
+ +
+
+
Дополнительно
+
+
+
+ {{ form.is_anonymous }} + +
+
+ + {{ form.special_instructions }} +
+
+
+ + +
+
+ + + Отмена + +
+
+
+
+ + + + + +{% endblock %}