feat: Добавлена возможность ручного изменения цены товаров/комплектов в заказе
- Добавлено поле is_custom_price в модель OrderItem для отслеживания ручных изменений - Добавлены свойства original_price и price_difference для отображения оригинальной цены и разницы - Поле цены теперь редактируемое (убран атрибут readonly) - Добавлены визуальные индикаторы: бейдж "Изменена" и информация об оригинальной цене - JavaScript автоматически отслеживает изменения цены и устанавливает флаг is_custom_price - В детальном просмотре заказа показывается информация о кастомных ценах с разницей - Цена товара в каталоге не изменяется - изменения только для конкретного заказа 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
156
myproject/orders/forms.py
Normal file
156
myproject/orders/forms.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
@@ -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='Цена изменена вручную'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -4,6 +4,7 @@ from accounts.models import CustomUser
|
|||||||
from customers.models import Customer, Address
|
from customers.models import Customer, Address
|
||||||
from products.models import Product, ProductKit
|
from products.models import Product, ProductKit
|
||||||
from shops.models import Shop
|
from shops.models import Shop
|
||||||
|
from simple_history.models import HistoricalRecords
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
@@ -28,16 +29,10 @@ class Order(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Тип доставки
|
# Тип доставки
|
||||||
DELIVERY_TYPE_CHOICES = [
|
is_delivery = models.BooleanField(
|
||||||
('courier', 'Курьерская доставка'),
|
default=True,
|
||||||
('pickup', 'Самовывоз'),
|
verbose_name="С доставкой",
|
||||||
]
|
help_text="True - доставка курьером, False - самовывоз"
|
||||||
|
|
||||||
delivery_type = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
choices=DELIVERY_TYPE_CHOICES,
|
|
||||||
default='courier',
|
|
||||||
verbose_name="Тип доставки"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Адрес доставки (для курьерской доставки)
|
# Адрес доставки (для курьерской доставки)
|
||||||
@@ -64,15 +59,22 @@ class Order(models.Model):
|
|||||||
|
|
||||||
# Дата и время доставки/самовывоза
|
# Дата и время доставки/самовывоза
|
||||||
delivery_date = models.DateField(
|
delivery_date = models.DateField(
|
||||||
verbose_name="Дата доставки/самовывоза"
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name="Дата доставки/самовывоза",
|
||||||
|
help_text="Может быть заполнено позже"
|
||||||
)
|
)
|
||||||
|
|
||||||
delivery_time_start = models.TimeField(
|
delivery_time_start = models.TimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
verbose_name="Время от",
|
verbose_name="Время от",
|
||||||
help_text="Начало временного интервала"
|
help_text="Начало временного интервала"
|
||||||
)
|
)
|
||||||
|
|
||||||
delivery_time_end = models.TimeField(
|
delivery_time_end = models.TimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
verbose_name="Время до",
|
verbose_name="Время до",
|
||||||
help_text="Конец временного интервала"
|
help_text="Конец временного интервала"
|
||||||
)
|
)
|
||||||
@@ -130,7 +132,62 @@ class Order(models.Model):
|
|||||||
help_text="Общая сумма заказа включая доставку"
|
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(
|
is_anonymous = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
verbose_name="Анонимная доставка",
|
verbose_name="Анонимная доставка",
|
||||||
@@ -155,6 +212,19 @@ class Order(models.Model):
|
|||||||
verbose_name="Дата обновления"
|
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:
|
class Meta:
|
||||||
verbose_name = "Заказ"
|
verbose_name = "Заказ"
|
||||||
verbose_name_plural = "Заказы"
|
verbose_name_plural = "Заказы"
|
||||||
@@ -162,7 +232,8 @@ class Order(models.Model):
|
|||||||
models.Index(fields=['customer']),
|
models.Index(fields=['customer']),
|
||||||
models.Index(fields=['status']),
|
models.Index(fields=['status']),
|
||||||
models.Index(fields=['delivery_date']),
|
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=['created_at']),
|
||||||
models.Index(fields=['order_number']),
|
models.Index(fields=['order_number']),
|
||||||
]
|
]
|
||||||
@@ -189,14 +260,14 @@ class Order(models.Model):
|
|||||||
"""Валидация модели"""
|
"""Валидация модели"""
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
# Проверка: для курьерской доставки обязателен адрес
|
# Проверка: для доставки обязателен адрес
|
||||||
if self.delivery_type == 'courier' and not self.delivery_address:
|
if self.is_delivery and not self.delivery_address:
|
||||||
raise ValidationError({
|
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({
|
raise ValidationError({
|
||||||
'pickup_shop': 'Для самовывоза необходимо выбрать точку самовывоза'
|
'pickup_shop': 'Для самовывоза необходимо выбрать точку самовывоза'
|
||||||
})
|
})
|
||||||
@@ -211,22 +282,46 @@ class Order(models.Model):
|
|||||||
def calculate_total(self):
|
def calculate_total(self):
|
||||||
"""Рассчитывает итоговую сумму заказа"""
|
"""Рассчитывает итоговую сумму заказа"""
|
||||||
items_total = sum(item.get_total_price() for item in self.items.all())
|
items_total = sum(item.get_total_price() for item in self.items.all())
|
||||||
self.total_amount = items_total + self.delivery_cost
|
subtotal = items_total + self.delivery_cost
|
||||||
|
self.total_amount = subtotal - self.discount_amount
|
||||||
return self.total_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
|
@property
|
||||||
def delivery_info(self):
|
def delivery_info(self):
|
||||||
"""Информация о доставке для отображения"""
|
"""Информация о доставке для отображения"""
|
||||||
if self.delivery_type == 'courier':
|
if self.is_delivery:
|
||||||
|
if self.delivery_address:
|
||||||
return f"Доставка по адресу: {self.delivery_address.full_address}"
|
return f"Доставка по адресу: {self.delivery_address.full_address}"
|
||||||
elif self.delivery_type == 'pickup':
|
return "Доставка (адрес не указан)"
|
||||||
|
else:
|
||||||
|
if self.pickup_shop:
|
||||||
return f"Самовывоз из: {self.pickup_shop.name}"
|
return f"Самовывоз из: {self.pickup_shop.name}"
|
||||||
return "Не указано"
|
return "Самовывоз (точка не указана)"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def delivery_time_window(self):
|
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 f"{self.delivery_time_start.strftime('%H:%M')} - {self.delivery_time_end.strftime('%H:%M')}"
|
||||||
|
return "Время не указано"
|
||||||
|
|
||||||
|
|
||||||
class OrderItem(models.Model):
|
class OrderItem(models.Model):
|
||||||
@@ -272,6 +367,12 @@ class OrderItem(models.Model):
|
|||||||
help_text="Цена на момент создания заказа (фиксируется)"
|
help_text="Цена на момент создания заказа (фиксируется)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_custom_price = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Цена изменена вручную",
|
||||||
|
help_text="True если цена была изменена вручную при создании заказа"
|
||||||
|
)
|
||||||
|
|
||||||
# Временные метки
|
# Временные метки
|
||||||
created_at = models.DateTimeField(
|
created_at = models.DateTimeField(
|
||||||
auto_now_add=True,
|
auto_now_add=True,
|
||||||
@@ -332,3 +433,84 @@ class OrderItem(models.Model):
|
|||||||
elif self.product_kit:
|
elif self.product_kit:
|
||||||
return self.product_kit.name
|
return self.product_kit.name
|
||||||
return "Не указано"
|
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()
|
||||||
|
|||||||
319
myproject/orders/templates/orders/order_detail.html
Normal file
319
myproject/orders/templates/orders/order_detail.html
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Заказ {{ order.order_number }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col">
|
||||||
|
<h1>Заказ {{ order.order_number }}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<a href="{% url 'orders:order-update' order.pk %}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-pencil"></i> Редактировать
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'orders:order-delete' order.pk %}" class="btn btn-danger">
|
||||||
|
<i class="bi bi-trash"></i> Удалить
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'orders:order-list' %}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> К списку
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Левая колонка -->
|
||||||
|
<div class="col-md-8">
|
||||||
|
<!-- Основная информация -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Информация о заказе</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-4"><strong>Клиент:</strong></div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<a href="{% url 'customers:customer-detail' order.customer.pk %}">
|
||||||
|
{{ order.customer.name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-4"><strong>Телефон:</strong></div>
|
||||||
|
<div class="col-md-8">{{ order.customer.phone }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-4"><strong>Статус:</strong></div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
{% if order.status == 'new' %}
|
||||||
|
<span class="badge bg-primary">Новый</span>
|
||||||
|
{% elif order.status == 'confirmed' %}
|
||||||
|
<span class="badge bg-success">Подтвержден</span>
|
||||||
|
{% elif order.status == 'in_assembly' %}
|
||||||
|
<span class="badge bg-warning">В сборке</span>
|
||||||
|
{% elif order.status == 'in_delivery' %}
|
||||||
|
<span class="badge bg-info">В доставке</span>
|
||||||
|
{% elif order.status == 'delivered' %}
|
||||||
|
<span class="badge bg-success">Доставлен</span>
|
||||||
|
{% elif order.status == 'cancelled' %}
|
||||||
|
<span class="badge bg-danger">Отменен</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-4"><strong>Создан:</strong></div>
|
||||||
|
<div class="col-md-8">{{ order.created_at|date:"d.m.Y H:i" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-4"><strong>Обновлен:</strong></div>
|
||||||
|
<div class="col-md-8">{{ order.updated_at|date:"d.m.Y H:i" }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Получатель -->
|
||||||
|
{% if not order.customer_is_recipient %}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Получатель</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-4"><strong>Имя получателя:</strong></div>
|
||||||
|
<div class="col-md-8">{{ order.recipient_name|default:"Не указано" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-4"><strong>Телефон получателя:</strong></div>
|
||||||
|
<div class="col-md-8">{{ order.recipient_phone|default:"Не указан" }}</div>
|
||||||
|
</div>
|
||||||
|
{% if order.is_anonymous %}
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<span class="badge bg-warning">Анонимная доставка</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Доставка -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Доставка</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-4"><strong>Тип:</strong></div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
{% if order.is_delivery %}
|
||||||
|
<span class="badge bg-info">Доставка курьером</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Самовывоз</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if order.is_delivery %}
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-4"><strong>Адрес:</strong></div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
{% if order.delivery_address %}
|
||||||
|
{{ order.delivery_address.full_address }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-danger">Не указан</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-4"><strong>Стоимость доставки:</strong></div>
|
||||||
|
<div class="col-md-8">{{ order.delivery_cost }} руб.</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-4"><strong>Точка самовывоза:</strong></div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
{% if order.pickup_shop %}
|
||||||
|
{{ order.pickup_shop.name }}<br>
|
||||||
|
<small class="text-muted">{{ order.pickup_shop.address }}</small>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-danger">Не указана</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-4"><strong>Дата:</strong></div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
{% if order.delivery_date %}
|
||||||
|
{{ order.delivery_date|date:"d.m.Y" }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Не указана</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-4"><strong>Время:</strong></div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
{% if order.delivery_time_start and order.delivery_time_end %}
|
||||||
|
{{ order.delivery_time_window }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Не указано</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if order.special_instructions %}
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-4"><strong>Особые пожелания:</strong></div>
|
||||||
|
<div class="col-md-8">{{ order.special_instructions }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if order.is_anonymous %}
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<span class="badge bg-warning">Анонимная доставка</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Товары -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Товары в заказе</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Наименование</th>
|
||||||
|
<th>Количество</th>
|
||||||
|
<th>Цена</th>
|
||||||
|
<th>Сумма</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in order.items.all %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.item_name }}</td>
|
||||||
|
<td>{{ item.quantity }}</td>
|
||||||
|
<td>
|
||||||
|
{{ item.price }} руб.
|
||||||
|
{% if item.is_custom_price %}
|
||||||
|
<span class="badge bg-warning ms-1">Изменена</span>
|
||||||
|
<br>
|
||||||
|
<small class="text-muted">
|
||||||
|
Оригинальная: {{ item.original_price }} руб.
|
||||||
|
{% if item.price_difference %}
|
||||||
|
{% if item.price_difference > 0 %}
|
||||||
|
<span class="text-success">(+{{ item.price_difference }} руб.)</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-danger">({{ item.price_difference }} руб.)</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td><strong>{{ item.get_total_price }} руб.</strong></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Правая колонка -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<!-- Оплата -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Оплата</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-6"><strong>Товары:</strong></div>
|
||||||
|
<div class="col-6 text-end">
|
||||||
|
{% with items_total=order.items.all|length %}
|
||||||
|
{% if items_total > 0 %}
|
||||||
|
{{ order.total_amount|floatformat:2 }} руб.
|
||||||
|
{% else %}
|
||||||
|
0.00 руб.
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if order.is_delivery %}
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-6"><strong>Доставка:</strong></div>
|
||||||
|
<div class="col-6 text-end">{{ order.delivery_cost }} руб.</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if order.discount_amount > 0 %}
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-6"><strong>Скидка:</strong></div>
|
||||||
|
<div class="col-6 text-end text-danger">-{{ order.discount_amount }} руб.</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<hr>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-6"><strong>Итого:</strong></div>
|
||||||
|
<div class="col-6 text-end"><h5>{{ order.total_amount }} руб.</h5></div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-6"><strong>Оплачено:</strong></div>
|
||||||
|
<div class="col-6 text-end">{{ order.amount_paid }} руб.</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-6"><strong>К оплате:</strong></div>
|
||||||
|
<div class="col-6 text-end text-danger"><strong>{{ order.amount_due }} руб.</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-12">
|
||||||
|
<strong>Статус оплаты:</strong>
|
||||||
|
{% if order.payment_status == 'paid' %}
|
||||||
|
<span class="badge bg-success w-100">Оплачен полностью</span>
|
||||||
|
{% elif order.payment_status == 'partial' %}
|
||||||
|
<span class="badge bg-warning w-100">Частично оплачен</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger w-100">Не оплачен</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-12">
|
||||||
|
<strong>Способ оплаты:</strong><br>
|
||||||
|
{{ order.get_payment_method_display }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- История платежей -->
|
||||||
|
{% if order.payments.all %}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">История платежей</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for payment in order.payments.all %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div><strong>{{ payment.amount }} руб.</strong></div>
|
||||||
|
<small class="text-muted">
|
||||||
|
{{ payment.payment_date|date:"d.m.Y H:i" }}<br>
|
||||||
|
{{ payment.get_payment_method_display }}
|
||||||
|
{% if payment.created_by %}
|
||||||
|
<br>Принял: {{ payment.created_by.get_full_name }}
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
536
myproject/orders/templates/orders/order_form.html
Normal file
536
myproject/orders/templates/orders/order_form.html
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col">
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<a href="{% url 'orders:order-list' %}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> Назад к списку
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" id="order-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<!-- Основная информация -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Основная информация</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.customer.id_for_label }}" class="form-label">
|
||||||
|
Клиент <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.customer }}
|
||||||
|
{% if form.customer.errors %}
|
||||||
|
<div class="text-danger">{{ form.customer.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.status.id_for_label }}" class="form-label">Статус</label>
|
||||||
|
{{ form.status }}
|
||||||
|
{% if form.status.errors %}
|
||||||
|
<div class="text-danger">{{ form.status.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Доставка -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Доставка</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
{{ form.is_delivery }}
|
||||||
|
<label class="form-check-label" for="{{ form.is_delivery.id_for_label }}">
|
||||||
|
С доставкой (снимите галочку для самовывоза)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
{{ form.customer_is_recipient }}
|
||||||
|
<label class="form-check-label" for="{{ form.customer_is_recipient.id_for_label }}">
|
||||||
|
Покупатель является получателем
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Поля получателя (показываются когда покупатель != получатель) -->
|
||||||
|
<div class="row" id="recipient-fields" style="display: none;">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.recipient_name.id_for_label }}" class="form-label">
|
||||||
|
Имя получателя <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.recipient_name }}
|
||||||
|
{% if form.recipient_name.errors %}
|
||||||
|
<div class="text-danger">{{ form.recipient_name.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.recipient_phone.id_for_label }}" class="form-label">
|
||||||
|
Телефон получателя <span class="text-danger">*</span>
|
||||||
|
</label>
|
||||||
|
{{ form.recipient_phone }}
|
||||||
|
{% if form.recipient_phone.errors %}
|
||||||
|
<div class="text-danger">{{ form.recipient_phone.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" id="delivery-fields">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.delivery_address.id_for_label }}" class="form-label">
|
||||||
|
Адрес доставки
|
||||||
|
</label>
|
||||||
|
{{ form.delivery_address }}
|
||||||
|
{% if form.delivery_address.errors %}
|
||||||
|
<div class="text-danger">{{ form.delivery_address.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.delivery_cost.id_for_label }}" class="form-label">Стоимость доставки</label>
|
||||||
|
{{ form.delivery_cost }}
|
||||||
|
{% if form.delivery_cost.errors %}
|
||||||
|
<div class="text-danger">{{ form.delivery_cost.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row" id="pickup-fields" style="display: none;">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.pickup_shop.id_for_label }}" class="form-label">
|
||||||
|
Точка самовывоза
|
||||||
|
</label>
|
||||||
|
{{ form.pickup_shop }}
|
||||||
|
{% if form.pickup_shop.errors %}
|
||||||
|
<div class="text-danger">{{ form.pickup_shop.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.delivery_date.id_for_label }}" class="form-label">Дата</label>
|
||||||
|
{{ form.delivery_date }}
|
||||||
|
{% if form.delivery_date.errors %}
|
||||||
|
<div class="text-danger">{{ form.delivery_date.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.delivery_time_start.id_for_label }}" class="form-label">Время от</label>
|
||||||
|
{{ form.delivery_time_start }}
|
||||||
|
{% if form.delivery_time_start.errors %}
|
||||||
|
<div class="text-danger">{{ form.delivery_time_start.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.delivery_time_end.id_for_label }}" class="form-label">Время до</label>
|
||||||
|
{{ form.delivery_time_end }}
|
||||||
|
{% if form.delivery_time_end.errors %}
|
||||||
|
<div class="text-danger">{{ form.delivery_time_end.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Товары в заказе -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Товары в заказе</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{{ formset.management_form }}
|
||||||
|
<div id="order-items-container">
|
||||||
|
{% for item_form in formset %}
|
||||||
|
<div class="order-item-form border rounded p-3 mb-3" data-form-index="{{ forloop.counter0 }}">
|
||||||
|
{{ item_form.id }}
|
||||||
|
{{ item_form.product }} <!-- Hidden field -->
|
||||||
|
{{ item_form.product_kit }} <!-- Hidden field -->
|
||||||
|
{{ item_form.is_custom_price }} <!-- Hidden field -->
|
||||||
|
|
||||||
|
<div class="row align-items-end">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Товар или комплект</label>
|
||||||
|
<select class="form-select select2-order-item" data-form-index="{{ forloop.counter0 }}">
|
||||||
|
<option value=""></option>
|
||||||
|
{% if item_form.instance.product %}
|
||||||
|
<option value="product_{{ item_form.instance.product.id }}" selected data-type="product" data-price="{{ item_form.instance.product.actual_price }}">
|
||||||
|
{{ item_form.instance.product.name }}{% if item_form.instance.product.sku %} ({{ item_form.instance.product.sku }}){% endif %}
|
||||||
|
</option>
|
||||||
|
{% elif item_form.instance.product_kit %}
|
||||||
|
<option value="kit_{{ item_form.instance.product_kit.id }}" selected data-type="kit" data-price="{{ item_form.instance.product_kit.actual_price }}">
|
||||||
|
{{ item_form.instance.product_kit.name }}{% if item_form.instance.product_kit.sku %} ({{ item_form.instance.product_kit.sku }}){% endif %}
|
||||||
|
</option>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Количество</label>
|
||||||
|
{{ item_form.quantity }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Цена</label>
|
||||||
|
<div class="position-relative">
|
||||||
|
{{ item_form.price }}
|
||||||
|
<span class="custom-price-badge badge bg-warning position-absolute top-0 end-0 mt-1 me-1" style="display: none;">
|
||||||
|
Изменена
|
||||||
|
</span>
|
||||||
|
<small class="text-muted original-price-info position-absolute" style="display: none; top: calc(100% + 2px); left: 0; white-space: nowrap;">
|
||||||
|
Оригинальная: <span class="original-price-value"></span> руб.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 text-end">
|
||||||
|
{% if formset.can_delete %}
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="form-check">
|
||||||
|
{{ item_form.DELETE }}
|
||||||
|
<label class="form-check-label" for="{{ item_form.DELETE.id_for_label }}">
|
||||||
|
Удалить
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if item_form.errors %}
|
||||||
|
<div class="alert alert-danger mt-2">{{ item_form.errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-secondary" id="add-item-btn">
|
||||||
|
<i class="bi bi-plus-circle"></i> Добавить товар
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Оплата и дополнительно -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Оплата</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.payment_method.id_for_label }}" class="form-label">Способ оплаты</label>
|
||||||
|
{{ form.payment_method }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.discount_amount.id_for_label }}" class="form-label">Скидка</label>
|
||||||
|
{{ form.discount_amount }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Дополнительно</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3 form-check">
|
||||||
|
{{ form.is_anonymous }}
|
||||||
|
<label class="form-check-label" for="{{ form.is_anonymous.id_for_label }}">
|
||||||
|
Анонимная доставка
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.special_instructions.id_for_label }}" class="form-label">Особые пожелания</label>
|
||||||
|
{{ form.special_instructions }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Кнопки -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
|
<i class="bi bi-check-circle"></i> {{ button_text }}
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'orders:order-list' %}" class="btn btn-secondary btn-lg">
|
||||||
|
<i class="bi bi-x-circle"></i> Отмена
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Подключение модуля Select2 для поиска товаров/комплектов -->
|
||||||
|
<script src="{% static 'products/js/select2-product-search.js' %}"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Инициализация Select2 для обычных полей (не товары)
|
||||||
|
$('.select2:not(.select2-order-item)').select2({
|
||||||
|
theme: 'bootstrap-5',
|
||||||
|
width: '100%',
|
||||||
|
language: 'ru'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Показ/скрытие полей доставки/самовывоза
|
||||||
|
const isDeliveryCheckbox = document.getElementById('{{ form.is_delivery.id_for_label }}');
|
||||||
|
const deliveryFields = document.getElementById('delivery-fields');
|
||||||
|
const pickupFields = document.getElementById('pickup-fields');
|
||||||
|
|
||||||
|
function toggleDeliveryFields() {
|
||||||
|
if (isDeliveryCheckbox.checked) {
|
||||||
|
deliveryFields.style.display = '';
|
||||||
|
pickupFields.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
deliveryFields.style.display = 'none';
|
||||||
|
pickupFields.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeliveryCheckbox.addEventListener('change', toggleDeliveryFields);
|
||||||
|
toggleDeliveryFields(); // Инициализация при загрузке
|
||||||
|
|
||||||
|
// Показ/скрытие полей получателя
|
||||||
|
const customerIsRecipientCheckbox = document.getElementById('{{ form.customer_is_recipient.id_for_label }}');
|
||||||
|
const recipientFields = document.getElementById('recipient-fields');
|
||||||
|
|
||||||
|
function toggleRecipientFields() {
|
||||||
|
if (customerIsRecipientCheckbox.checked) {
|
||||||
|
recipientFields.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
recipientFields.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customerIsRecipientCheckbox.addEventListener('change', toggleRecipientFields);
|
||||||
|
toggleRecipientFields(); // Инициализация при загрузке
|
||||||
|
|
||||||
|
// Инициализация Select2 для поиска товаров/комплектов
|
||||||
|
function initOrderItemSelect2(element) {
|
||||||
|
const $element = $(element);
|
||||||
|
const formIndex = element.dataset.formIndex;
|
||||||
|
|
||||||
|
// Инициализируем Select2 с AJAX поиском
|
||||||
|
window.initProductSelect2(
|
||||||
|
element,
|
||||||
|
'all', // Искать и товары, и комплекты
|
||||||
|
'{% url "products:api-search-products-variants" %}'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Обработка выбора элемента
|
||||||
|
$element.on('select2:select', function(e) {
|
||||||
|
const data = e.params.data;
|
||||||
|
const idParts = data.id.split('_');
|
||||||
|
const type = idParts[0]; // 'product' или 'kit'
|
||||||
|
const id = idParts[1];
|
||||||
|
|
||||||
|
// Найти скрытые поля product и product_kit
|
||||||
|
const form = element.closest('.order-item-form');
|
||||||
|
const productField = form.querySelector('[name$="-product"]');
|
||||||
|
const kitField = form.querySelector('[name$="-product_kit"]');
|
||||||
|
const priceField = form.querySelector('[name$="-price"]');
|
||||||
|
const isCustomPriceField = form.querySelector('[name$="-is_custom_price"]');
|
||||||
|
|
||||||
|
const originalPrice = data.actual_price || data.price || '';
|
||||||
|
|
||||||
|
// Установить значение в правильное поле
|
||||||
|
if (type === 'product') {
|
||||||
|
productField.value = id;
|
||||||
|
kitField.value = '';
|
||||||
|
priceField.value = originalPrice;
|
||||||
|
} else if (type === 'kit') {
|
||||||
|
kitField.value = id;
|
||||||
|
productField.value = '';
|
||||||
|
priceField.value = originalPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем оригинальную цену в data-атрибуте
|
||||||
|
priceField.dataset.originalPrice = originalPrice;
|
||||||
|
|
||||||
|
// Сбрасываем флаг кастомной цены
|
||||||
|
isCustomPriceField.value = 'false';
|
||||||
|
|
||||||
|
// Скрываем индикатор
|
||||||
|
const badge = form.querySelector('.custom-price-badge');
|
||||||
|
const priceInfo = form.querySelector('.original-price-info');
|
||||||
|
if (badge) badge.style.display = 'none';
|
||||||
|
if (priceInfo) priceInfo.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Очистка при удалении выбора
|
||||||
|
$element.on('select2:clear', function() {
|
||||||
|
const form = element.closest('.order-item-form');
|
||||||
|
form.querySelector('[name$="-product"]').value = '';
|
||||||
|
form.querySelector('[name$="-product_kit"]').value = '';
|
||||||
|
form.querySelector('[name$="-price"]').value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализировать все существующие формы товаров
|
||||||
|
document.querySelectorAll('.select2-order-item').forEach(initOrderItemSelect2);
|
||||||
|
|
||||||
|
// Функция для инициализации отслеживания изменения цены
|
||||||
|
function initPriceTracking(form) {
|
||||||
|
const priceField = form.querySelector('[name$="-price"]');
|
||||||
|
const isCustomPriceField = form.querySelector('[name$="-is_custom_price"]');
|
||||||
|
const badge = form.querySelector('.custom-price-badge');
|
||||||
|
const priceInfo = form.querySelector('.original-price-info');
|
||||||
|
const originalPriceValue = form.querySelector('.original-price-value');
|
||||||
|
|
||||||
|
if (!priceField) return;
|
||||||
|
|
||||||
|
// Отслеживание изменения цены вручную
|
||||||
|
priceField.addEventListener('input', function() {
|
||||||
|
const currentPrice = parseFloat(priceField.value) || 0;
|
||||||
|
const originalPrice = parseFloat(priceField.dataset.originalPrice) || 0;
|
||||||
|
|
||||||
|
// Если цена изменена и есть оригинальная цена
|
||||||
|
if (originalPrice && currentPrice !== originalPrice) {
|
||||||
|
isCustomPriceField.value = 'true';
|
||||||
|
badge.style.display = '';
|
||||||
|
priceInfo.style.display = '';
|
||||||
|
originalPriceValue.textContent = originalPrice.toFixed(2);
|
||||||
|
} else {
|
||||||
|
isCustomPriceField.value = 'false';
|
||||||
|
badge.style.display = 'none';
|
||||||
|
priceInfo.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверка при загрузке формы (для редактирования)
|
||||||
|
if (isCustomPriceField.value === 'True' || isCustomPriceField.value === 'true') {
|
||||||
|
badge.style.display = '';
|
||||||
|
priceInfo.style.display = '';
|
||||||
|
const originalPrice = parseFloat(priceField.dataset.originalPrice) || 0;
|
||||||
|
if (originalPrice) {
|
||||||
|
originalPriceValue.textContent = originalPrice.toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация отслеживания цены для всех существующих форм
|
||||||
|
document.querySelectorAll('.order-item-form').forEach(initPriceTracking);
|
||||||
|
|
||||||
|
// Динамическое добавление позиций товаров
|
||||||
|
const container = document.getElementById('order-items-container');
|
||||||
|
const addButton = document.getElementById('add-item-btn');
|
||||||
|
const totalFormsInput = document.querySelector('#id_items-TOTAL_FORMS');
|
||||||
|
|
||||||
|
addButton.addEventListener('click', function() {
|
||||||
|
const formCount = parseInt(totalFormsInput.value);
|
||||||
|
const lastForm = container.querySelector('.order-item-form:last-child');
|
||||||
|
const newForm = lastForm.cloneNode(true);
|
||||||
|
|
||||||
|
// Обновляем индексы в новой форме
|
||||||
|
const regex = new RegExp('items-(\\d+)-', 'g');
|
||||||
|
newForm.innerHTML = newForm.innerHTML.replace(regex, `items-${formCount}-`);
|
||||||
|
newForm.dataset.formIndex = formCount;
|
||||||
|
|
||||||
|
// Очищаем значения
|
||||||
|
newForm.querySelectorAll('input[type="hidden"]').forEach(input => {
|
||||||
|
if (!input.name.includes('-id')) {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
newForm.querySelectorAll('input[type="number"]').forEach(input => {
|
||||||
|
input.value = '';
|
||||||
|
});
|
||||||
|
// Очищаем поле цены
|
||||||
|
const priceField = newForm.querySelector('[name$="-price"]');
|
||||||
|
if (priceField) {
|
||||||
|
priceField.value = '';
|
||||||
|
delete priceField.dataset.originalPrice;
|
||||||
|
}
|
||||||
|
newForm.querySelectorAll('input[type="checkbox"]').forEach(input => {
|
||||||
|
if (input.name.includes('DELETE')) {
|
||||||
|
input.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Скрываем индикаторы кастомной цены
|
||||||
|
const badge = newForm.querySelector('.custom-price-badge');
|
||||||
|
const priceInfo = newForm.querySelector('.original-price-info');
|
||||||
|
if (badge) badge.style.display = 'none';
|
||||||
|
if (priceInfo) priceInfo.style.display = 'none';
|
||||||
|
|
||||||
|
// Удаляем и пересоздаем Select2
|
||||||
|
const select2Element = newForm.querySelector('.select2-order-item');
|
||||||
|
const $select2Element = $(select2Element);
|
||||||
|
|
||||||
|
// Если Select2 уже был инициализирован, уничтожаем его
|
||||||
|
if ($select2Element.data('select2')) {
|
||||||
|
$select2Element.select2('destroy');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Очищаем все опции, кроме первой пустой
|
||||||
|
select2Element.innerHTML = '<option value=""></option>';
|
||||||
|
select2Element.dataset.formIndex = formCount;
|
||||||
|
|
||||||
|
container.appendChild(newForm);
|
||||||
|
totalFormsInput.value = formCount + 1;
|
||||||
|
|
||||||
|
// Инициализируем Select2 для новой формы
|
||||||
|
initOrderItemSelect2(select2Element);
|
||||||
|
|
||||||
|
// Инициализируем отслеживание цены для новой формы
|
||||||
|
initPriceTracking(newForm);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Перед отправкой: удалить пустые формы
|
||||||
|
document.getElementById('order-form').addEventListener('submit', function(e) {
|
||||||
|
const forms = container.querySelectorAll('.order-item-form');
|
||||||
|
forms.forEach(form => {
|
||||||
|
const productField = form.querySelector('[name$="-product"]');
|
||||||
|
const kitField = form.querySelector('[name$="-product_kit"]');
|
||||||
|
const deleteCheckbox = form.querySelector('[name$="-DELETE"]');
|
||||||
|
|
||||||
|
// Если оба поля пусты - пометить на удаление
|
||||||
|
if (!productField.value && !kitField.value && deleteCheckbox) {
|
||||||
|
deleteCheckbox.checked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user