From 885ac839e266084e43ac3024da5a39f788ba93e6 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Tue, 11 Nov 2025 15:48:50 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B0=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC?= =?UTF-8?q?=D0=B0=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=81=D1=82=D0=BE=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D1=8C?= =?UTF-8?q?=D1=8E=20=D0=B4=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=20=D0=B1=D0=B0=D0=B3=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Изменения: 1. **Стоимость доставки (автоматическая/ручная)**: - Добавлено поле `is_custom_delivery_cost` в модель Order для отслеживания ручной стоимости - Создан сервис DeliveryCostCalculator для автоматического расчета стоимости доставки - Реализована логика: бесплатная доставка от 100 руб., иначе 15 руб. - В форме поле "Ручная стоимость доставки" - если заполнено, используется ручная стоимость, если пустое - автоматический расчет - Добавлены методы Order.get_delivery_cost(), set_delivery_cost(), reset_delivery_cost(), recalculate_delivery_cost() 2. **Исправление бага выбора клиента в Select2**: - Удален избыточный обработчик события select2:opening, который блокировал клики - Добавлены проверки на наличие e.params.data в обработчиках select2:selecting и select2:select - Теперь выбор клиента работает корректно, создается черновик заказа и происходит редирект 3. **Опциональные поля адреса**: - Поля recipient_name, street, building_number в модели Address сделаны необязательными (blank=True, null=True) - Обновлены методы __str__ и full_address для безопасной работы с None значениями 4. **UI улучшения**: - Удалена звездочка обязательности с полей адреса - Добавлена подсказка под полем ручной стоимости доставки о правилах автоматического расчета 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- myproject/orders/forms.py | 29 ++++ .../0005_add_custom_delivery_cost_flag.py | 46 ++++++ myproject/orders/models.py | 105 +++++++++++++- .../services/delivery_cost_calculator.py | 95 ++++++++++++ .../orders/templates/orders/order_form.html | 136 ++++++++++-------- 5 files changed, 351 insertions(+), 60 deletions(-) create mode 100644 myproject/orders/migrations/0005_add_custom_delivery_cost_flag.py create mode 100644 myproject/orders/services/delivery_cost_calculator.py diff --git a/myproject/orders/forms.py b/myproject/orders/forms.py index d90ed55..88e5e63 100644 --- a/myproject/orders/forms.py +++ b/myproject/orders/forms.py @@ -164,6 +164,11 @@ class OrderForm(forms.ModelForm): self.fields['recipient_name'].required = False self.fields['recipient_phone'].required = False + # Поле ручной стоимости доставки опционально + self.fields['delivery_cost'].required = False + self.fields['delivery_cost'].label = 'Ручная стоимость доставки' + self.fields['delivery_cost'].help_text = 'Оставьте пустым для автоматического расчета' + # Инициализируем queryset для address_from_history # Это будет переопределено в представлении после выбора клиента if self.instance.pk and self.instance.customer: @@ -176,6 +181,30 @@ class OrderForm(forms.ModelForm): order__in=customer_orders ).distinct().order_by('-created_at') + def save(self, commit=True): + """ + Сохраняет форму с учетом автоматического/ручного расчета стоимости доставки. + Логика: + - Если delivery_cost заполнено → используется ручное значение (is_custom_delivery_cost = True) + - Если delivery_cost пустое → автоматический расчет (is_custom_delivery_cost = False) + """ + instance = super().save(commit=False) + + # Получаем значение ручной стоимости доставки + delivery_cost = self.cleaned_data.get('delivery_cost') + + if delivery_cost is not None and delivery_cost > 0: + # Ручное значение указано + instance.set_delivery_cost(delivery_cost, is_custom=True) + else: + # Пустое поле или 0 → автоматический расчет + instance.reset_delivery_cost() + + if commit: + instance.save() + + return instance + class OrderItemForm(forms.ModelForm): """Форма для позиции заказа""" diff --git a/myproject/orders/migrations/0005_add_custom_delivery_cost_flag.py b/myproject/orders/migrations/0005_add_custom_delivery_cost_flag.py new file mode 100644 index 0000000..45bea62 --- /dev/null +++ b/myproject/orders/migrations/0005_add_custom_delivery_cost_flag.py @@ -0,0 +1,46 @@ +# Generated by Django 5.0.10 on 2025-11-11 09:52 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customers', '0002_remove_address_model'), + ('orders', '0004_remove_address_orders_addr_distric_fd94e9_idx_and_more'), + ('shops', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='historicalorder', + name='is_custom_delivery_cost', + field=models.BooleanField(default=False, help_text='True если стоимость доставки была изменена вручную', verbose_name='Стоимость доставки установлена вручную'), + ), + migrations.AddField( + model_name='order', + name='is_custom_delivery_cost', + field=models.BooleanField(default=False, help_text='True если стоимость доставки была изменена вручную', verbose_name='Стоимость доставки установлена вручную'), + ), + migrations.AlterField( + model_name='address', + name='building_number', + field=models.CharField(blank=True, max_length=20, null=True, verbose_name='Номер здания'), + ), + migrations.AlterField( + model_name='address', + name='recipient_name', + field=models.CharField(blank=True, help_text='Имя человека, которому будет доставлен заказ', max_length=200, null=True, verbose_name='Имя получателя'), + ), + migrations.AlterField( + model_name='address', + name='street', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Улица'), + ), + migrations.AddIndex( + model_name='order', + index=models.Index(fields=['is_custom_delivery_cost'], name='orders_orde_is_cust_108e98_idx'), + ), + ] diff --git a/myproject/orders/models.py b/myproject/orders/models.py index 26be6fe..6e4fa5e 100644 --- a/myproject/orders/models.py +++ b/myproject/orders/models.py @@ -15,6 +15,8 @@ class Address(models.Model): # Информация о получателе recipient_name = models.CharField( max_length=200, + blank=True, + null=True, verbose_name="Имя получателя", help_text="Имя человека, которому будет доставлен заказ" ) @@ -29,11 +31,15 @@ class Address(models.Model): street = models.CharField( max_length=255, + blank=True, + null=True, verbose_name="Улица" ) building_number = models.CharField( max_length=20, + blank=True, + null=True, verbose_name="Номер здания" ) @@ -94,17 +100,43 @@ class Address(models.Model): ordering = ['-created_at'] def __str__(self): - address_line = f"{self.street}, {self.building_number}" + # Собираем компоненты адреса + address_parts = [] + if self.street: + address_parts.append(self.street) + if self.building_number: + address_parts.append(self.building_number) if self.apartment_number: - address_line += f", кв/офис {self.apartment_number}" - return f"{self.recipient_name} - {address_line}" + address_parts.append(f"кв/офис {self.apartment_number}") + + address_line = ", ".join(address_parts) if address_parts else "Адрес не указан" + + # Формируем строку с именем получателя + if self.recipient_name: + return f"{self.recipient_name} - {address_line}" + return address_line @property def full_address(self): """Полный адрес для доставки""" - address = f"{self.street}, {self.building_number}" + # Собираем основные компоненты адреса + address_parts = [] + if self.street: + address_parts.append(self.street) + if self.building_number: + address_parts.append(self.building_number) + + # Если нет основных данных, возвращаем сообщение + if not address_parts: + return "Адрес не указан" + + address = ", ".join(address_parts) + + # Добавляем квартиру/офис if self.apartment_number: address += f", кв/офис {self.apartment_number}" + + # Собираем дополнительные детали details = [] if self.entrance: details.append(f"подъезд {self.entrance}") @@ -112,6 +144,7 @@ class Address(models.Model): details.append(f"этаж {self.floor}") if details: address += f" ({', '.join(details)})" + return address @@ -193,6 +226,12 @@ class Order(models.Model): help_text="0 для самовывоза" ) + is_custom_delivery_cost = models.BooleanField( + default=False, + verbose_name="Стоимость доставки установлена вручную", + help_text="True если стоимость доставки была изменена вручную" + ) + # Статус заказа STATUS_CHOICES = [ ('draft', 'Черновик'), @@ -351,6 +390,7 @@ class Order(models.Model): models.Index(fields=['payment_status']), models.Index(fields=['created_at']), models.Index(fields=['order_number']), + models.Index(fields=['is_custom_delivery_cost']), ] ordering = ['-created_at'] @@ -387,9 +427,56 @@ class Order(models.Model): 'delivery_time_end': 'Время окончания должно быть позже времени начала' }) + def get_delivery_cost(self): + """ + Возвращает стоимость доставки: + - Если установлена вручную - использует ручное значение + - Если автоматическая - вычисляет на основе правил + + Returns: + Decimal: Стоимость доставки + """ + if self.is_custom_delivery_cost: + return self.delivery_cost + else: + from orders.services.delivery_cost_calculator import DeliveryCostCalculator + return DeliveryCostCalculator.calculate(self) + + def set_delivery_cost(self, cost, is_custom=True): + """ + Устанавливает стоимость доставки. + + Args: + cost: Новая стоимость доставки (Decimal) + is_custom: True если устанавливается вручную, False если автоматически + """ + self.delivery_cost = cost + self.is_custom_delivery_cost = is_custom + + def reset_delivery_cost(self): + """ + Сбрасывает стоимость доставки на автоматический расчет. + """ + from orders.services.delivery_cost_calculator import DeliveryCostCalculator + self.delivery_cost = DeliveryCostCalculator.calculate(self) + self.is_custom_delivery_cost = False + + def recalculate_delivery_cost(self): + """ + Пересчитывает стоимость доставки, если она не установлена вручную. + Используется при изменении параметров заказа (товаров, адреса и т.д.) + """ + if not self.is_custom_delivery_cost: + from orders.services.delivery_cost_calculator import DeliveryCostCalculator + self.delivery_cost = DeliveryCostCalculator.calculate(self) + def calculate_total(self): """Рассчитывает итоговую сумму заказа""" items_total = sum(item.get_total_price() for item in self.items.all()) + + # Пересчитываем стоимость доставки если она автоматическая + self.recalculate_delivery_cost() + subtotal = items_total + self.delivery_cost self.total_amount = subtotal - self.discount_amount return self.total_amount @@ -416,6 +503,16 @@ class Order(models.Model): """Остаток к оплате""" return max(self.total_amount - self.amount_paid, 0) + @property + def delivery_cost_display(self): + """ + Возвращает строку для отображения стоимости доставки с пометкой. + Полезно в админке и шаблонах. + """ + cost = self.get_delivery_cost() + suffix = " (ручная)" if self.is_custom_delivery_cost else " (авто)" + return f"{cost} руб.{suffix}" + @property def delivery_info(self): """Информация о доставке для отображения""" diff --git a/myproject/orders/services/delivery_cost_calculator.py b/myproject/orders/services/delivery_cost_calculator.py new file mode 100644 index 0000000..a263643 --- /dev/null +++ b/myproject/orders/services/delivery_cost_calculator.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +""" +Сервис для расчета стоимости доставки. +Содержит расширяемую логику вычисления на основе различных условий. +""" +from decimal import Decimal +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from orders.models import Order + + +class DeliveryCostCalculator: + """ + Калькулятор стоимости доставки. + Применяет различные правила для автоматического расчета. + """ + + # Константы для правил расчета + FREE_DELIVERY_THRESHOLD = Decimal('100.00') # Бесплатная доставка от суммы + BASE_DELIVERY_COST = Decimal('15.00') # Базовая стоимость доставки + MIN_DELIVERY_COST = Decimal('0.00') # Минимальная стоимость + + @classmethod + def calculate(cls, order: 'Order') -> Decimal: + """ + Рассчитывает стоимость доставки на основе условий заказа. + + Args: + order: Заказ для расчета + + Returns: + Decimal: Рассчитанная стоимость доставки + """ + # Самовывоз - доставка бесплатная + if not order.is_delivery: + return cls.MIN_DELIVERY_COST + + # Рассчитываем сумму товаров + items_total = sum( + item.get_total_price() + for item in order.items.all() + ) + + # Применяем правила расчета + cost = cls._apply_calculation_rules(order, items_total) + + return cost + + @classmethod + def _apply_calculation_rules(cls, order: 'Order', items_total: Decimal) -> Decimal: + """ + Применяет правила расчета стоимости доставки. + Этот метод легко расширить для добавления новых правил. + + Args: + order: Заказ + items_total: Сумма товаров в заказе + + Returns: + Decimal: Стоимость доставки + """ + # Правило 1: Бесплатная доставка при заказе от определенной суммы + if items_total >= cls.FREE_DELIVERY_THRESHOLD: + return cls.MIN_DELIVERY_COST + + # Правило 2: Базовая стоимость доставки + cost = cls.BASE_DELIVERY_COST + + # Правило 3: Можно добавить расчет по адресу + # if order.delivery_address: + # cost += cls._calculate_distance_cost(order.delivery_address) + + # Правило 4: Можно добавить надбавку за срочность + # if cls._is_urgent_delivery(order): + # cost *= Decimal('1.5') + + return cost + + @classmethod + def _calculate_distance_cost(cls, address) -> Decimal: + """ + Рассчитывает надбавку за расстояние. + Placeholder для будущей реализации с геокодингом. + """ + # TODO: Интеграция с картами для расчета расстояния + return Decimal('0.00') + + @classmethod + def _is_urgent_delivery(cls, order: 'Order') -> bool: + """ + Проверяет, является ли доставка срочной. + """ + # TODO: Логика определения срочности + return False diff --git a/myproject/orders/templates/orders/order_form.html b/myproject/orders/templates/orders/order_form.html index 6f2240f..cef2de1 100644 --- a/myproject/orders/templates/orders/order_form.html +++ b/myproject/orders/templates/orders/order_form.html @@ -213,7 +213,7 @@
{{ form.address_street }} {% if form.address_street.errors %} @@ -224,7 +224,7 @@
{{ form.address_building_number }} {% if form.address_building_number.errors %} @@ -297,11 +297,19 @@
-
- {{ form.address_confirm_with_recipient }} - +
+ +
+ + +
@@ -310,12 +318,41 @@
Получатель
- -
- {{ form.customer_is_recipient }} - + +
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+ + +
@@ -323,7 +360,7 @@
{{ form.recipient_name }} {% if form.recipient_name.errors %} @@ -334,7 +371,7 @@
{{ form.recipient_phone }} {% if form.recipient_phone.errors %} @@ -349,11 +386,17 @@
- + {{ form.delivery_cost }} {% if form.delivery_cost.errors %}
{{ form.delivery_cost.errors }}
{% endif %} + + + Оставьте пустым для автоматического расчета (бесплатно от 100 руб., иначе 15 руб.) +
@@ -666,11 +709,19 @@ function initCustomerSelect2() { // Обработчик для перехвата ПЕРЕД выбором (используется для фальшивых опций) $customerSelect.on('select2:selecting', function(e) { + console.log('9. Событие select2:selecting, e.params:', e.params); + + // Проверяем наличие e.params и e.params.data + if (!e.params || !e.params.data) { + console.log('9a. Нет данных в e.params на selecting, пропускаем'); + return; + } + const data = e.params.data; - console.log('9a. Попытка выбрать элемент (перед выбором):', data); + console.log('9b. Попытка выбрать элемент (перед выбором):', data); if (data.is_create_option) { - console.log('9b. Это опция создания клиента - предотвращаем выбор и открываем модаль'); + console.log('9c. Это опция создания клиента - предотвращаем выбор и открываем модаль'); // Предотвращаем выбор этой опции e.preventDefault(); // Очищаем значение @@ -679,48 +730,21 @@ function initCustomerSelect2() { window.openCreateCustomerModal(data.search_text); return false; } - }); - // Обработчик прямого клика на результаты (для "create option") - $customerSelect.on('select2:opening', function(e) { - console.log('9_open. Dropdown открывается, добавляем обработчик клика'); - // Добавляем обработчик на клик по результатам в следующем event tick - setTimeout(function() { - const resultsContainer = document.querySelector('.select2-results'); - console.log('9_search. resultsContainer найден:', !!resultsContainer); - if (resultsContainer) { - resultsContainer.addEventListener('click', function handleCreateOptionClick(event) { - console.log('9_click. Клик по результатам:', event.target); - const target = event.target.closest('.select2-results__option'); - console.log('9_option. Ближайший option:', target); - if (!target) return; - - // Проверяем текст опции - если это "Создать клиента", открываем модаль - const optionText = target.textContent.trim(); - console.log('9_text. Текст опции:', optionText); - - if (optionText.startsWith('Создать клиента:')) { - console.log('9c. Клик на create option напрямую'); - // Извлекаем поисковый текст (удаляем "Создать клиента: ") - const searchText = optionText.replace(/^Создать\s+клиента:\s*"([^"]*)"\s*$/, '$1') || optionText; - - console.log('9d. Открываем модаль с текстом:', searchText); - window.openCreateCustomerModal(searchText); - - // Закрываем dropdown - $customerSelect.select2('close'); - - // Удаляем обработчик - resultsContainer.removeEventListener('click', handleCreateOptionClick); - } - }); - } - }, 0); + console.log('9d. Обычный клиент, разрешаем выбор'); }); $customerSelect.on('select2:select', function(e) { + console.log('10. Событие select2:select, e.params:', e.params); + + // Проверяем наличие e.params и e.params.data + if (!e.params || !e.params.data) { + console.log('10a. Нет данных в e.params, пропускаем обработку'); + return; + } + const data = e.params.data; - console.log('10. Выбран элемент:', data); + console.log('10b. Выбран элемент:', data); if (data.is_create_option) { console.log('11. Открываем модальное окно для создания клиента'); @@ -731,7 +755,7 @@ function initCustomerSelect2() { window.openCreateCustomerModal(data.search_text); } else { // Триггерим нативное change событие для других обработчиков (например, draft-creator.js) - console.log('12. Триггерим нативное change событие'); + console.log('12. Триггерим нативное change событие для customer ID:', data.id); const changeEvent = new Event('change', { bubbles: true }); this.dispatchEvent(changeEvent); }