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:
2025-11-07 10:44:46 +03:00
parent 9e430bca18
commit 2bf2afb56f
5 changed files with 1234 additions and 23 deletions

156
myproject/orders/forms.py Normal file
View 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,
)

View File

@@ -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='Цена изменена вручную'),
),
]

View File

@@ -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()

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

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