Реализована система управления стоимостью доставки и исправлен баг выбора клиента

Изменения:

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 <noreply@anthropic.com>
This commit is contained in:
2025-11-11 15:48:50 +03:00
parent 6c207e9451
commit 885ac839e2
5 changed files with 351 additions and 60 deletions

View File

@@ -164,6 +164,11 @@ class OrderForm(forms.ModelForm):
self.fields['recipient_name'].required = False self.fields['recipient_name'].required = False
self.fields['recipient_phone'].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 # Инициализируем queryset для address_from_history
# Это будет переопределено в представлении после выбора клиента # Это будет переопределено в представлении после выбора клиента
if self.instance.pk and self.instance.customer: if self.instance.pk and self.instance.customer:
@@ -176,6 +181,30 @@ class OrderForm(forms.ModelForm):
order__in=customer_orders order__in=customer_orders
).distinct().order_by('-created_at') ).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): class OrderItemForm(forms.ModelForm):
"""Форма для позиции заказа""" """Форма для позиции заказа"""

View File

@@ -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'),
),
]

View File

@@ -15,6 +15,8 @@ class Address(models.Model):
# Информация о получателе # Информация о получателе
recipient_name = models.CharField( recipient_name = models.CharField(
max_length=200, max_length=200,
blank=True,
null=True,
verbose_name="Имя получателя", verbose_name="Имя получателя",
help_text="Имя человека, которому будет доставлен заказ" help_text="Имя человека, которому будет доставлен заказ"
) )
@@ -29,11 +31,15 @@ class Address(models.Model):
street = models.CharField( street = models.CharField(
max_length=255, max_length=255,
blank=True,
null=True,
verbose_name="Улица" verbose_name="Улица"
) )
building_number = models.CharField( building_number = models.CharField(
max_length=20, max_length=20,
blank=True,
null=True,
verbose_name="Номер здания" verbose_name="Номер здания"
) )
@@ -94,17 +100,43 @@ class Address(models.Model):
ordering = ['-created_at'] ordering = ['-created_at']
def __str__(self): 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: if self.apartment_number:
address_line += f", кв/офис {self.apartment_number}" 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 f"{self.recipient_name} - {address_line}"
return address_line
@property @property
def full_address(self): 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: if self.apartment_number:
address += f", кв/офис {self.apartment_number}" address += f", кв/офис {self.apartment_number}"
# Собираем дополнительные детали
details = [] details = []
if self.entrance: if self.entrance:
details.append(f"подъезд {self.entrance}") details.append(f"подъезд {self.entrance}")
@@ -112,6 +144,7 @@ class Address(models.Model):
details.append(f"этаж {self.floor}") details.append(f"этаж {self.floor}")
if details: if details:
address += f" ({', '.join(details)})" address += f" ({', '.join(details)})"
return address return address
@@ -193,6 +226,12 @@ class Order(models.Model):
help_text="0 для самовывоза" help_text="0 для самовывоза"
) )
is_custom_delivery_cost = models.BooleanField(
default=False,
verbose_name="Стоимость доставки установлена вручную",
help_text="True если стоимость доставки была изменена вручную"
)
# Статус заказа # Статус заказа
STATUS_CHOICES = [ STATUS_CHOICES = [
('draft', 'Черновик'), ('draft', 'Черновик'),
@@ -351,6 +390,7 @@ class Order(models.Model):
models.Index(fields=['payment_status']), 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']),
models.Index(fields=['is_custom_delivery_cost']),
] ]
ordering = ['-created_at'] ordering = ['-created_at']
@@ -387,9 +427,56 @@ class Order(models.Model):
'delivery_time_end': 'Время окончания должно быть позже времени начала' '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): 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.recalculate_delivery_cost()
subtotal = items_total + self.delivery_cost subtotal = items_total + self.delivery_cost
self.total_amount = subtotal - self.discount_amount self.total_amount = subtotal - self.discount_amount
return self.total_amount return self.total_amount
@@ -416,6 +503,16 @@ class Order(models.Model):
"""Остаток к оплате""" """Остаток к оплате"""
return max(self.total_amount - self.amount_paid, 0) 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 @property
def delivery_info(self): def delivery_info(self):
"""Информация о доставке для отображения""" """Информация о доставке для отображения"""

View File

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

View File

@@ -213,7 +213,7 @@
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="{{ form.address_street.id_for_label }}" class="form-label"> <label for="{{ form.address_street.id_for_label }}" class="form-label">
{{ form.address_street.label }} <span class="text-danger">*</span> {{ form.address_street.label }}
</label> </label>
{{ form.address_street }} {{ form.address_street }}
{% if form.address_street.errors %} {% if form.address_street.errors %}
@@ -224,7 +224,7 @@
<div class="col-md-3"> <div class="col-md-3">
<div class="mb-3"> <div class="mb-3">
<label for="{{ form.address_building_number.id_for_label }}" class="form-label"> <label for="{{ form.address_building_number.id_for_label }}" class="form-label">
{{ form.address_building_number.label }} <span class="text-danger">*</span> {{ form.address_building_number.label }}
</label> </label>
{{ form.address_building_number }} {{ form.address_building_number }}
{% if form.address_building_number.errors %} {% if form.address_building_number.errors %}
@@ -297,33 +297,70 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<div class="mb-3 form-check"> <div class="mb-3">
{{ form.address_confirm_with_recipient }} <!-- Крупный переключатель (switch) -->
<label class="form-check-label" for="{{ form.address_confirm_with_recipient.id_for_label }}"> <div class="form-check form-switch" style="padding-left: 3.5em;">
<input class="form-check-input" type="checkbox" role="switch"
id="{{ form.address_confirm_with_recipient.id_for_label }}"
name="{{ form.address_confirm_with_recipient.name }}"
style="width: 3em; height: 1.5em; cursor: pointer;">
<label class="form-check-label" for="{{ form.address_confirm_with_recipient.id_for_label }}"
style="font-size: 1.1em; font-weight: 500; cursor: pointer; padding-left: 0.5em;">
<i class="bi bi-telephone-fill text-primary"></i>
{{ form.address_confirm_with_recipient.label }} {{ form.address_confirm_with_recipient.label }}
</label> </label>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Получатель --> <!-- Получатель -->
<div class="border-top pt-3 mt-3"> <div class="border-top pt-3 mt-3">
<h6 class="mb-3">Получатель</h6> <h6 class="mb-3">Получатель</h6>
<!-- Чекбокс "Покупатель = получатель" --> <!-- Поля получателя из модели Address -->
<div class="mb-3 form-check"> <div class="row">
{{ form.customer_is_recipient }} <div class="col-md-6">
<label class="form-check-label" for="{{ form.customer_is_recipient.id_for_label }}"> <div class="mb-3">
<label for="id_address_recipient_name" class="form-label">
Имя получателя
</label>
<input type="text" name="address_recipient_name" id="id_address_recipient_name"
class="form-control" placeholder="Имя получателя">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="id_address_recipient_phone" class="form-label">
Телефон получателя
</label>
<input type="text" name="address_recipient_phone" id="id_address_recipient_phone"
class="form-control" placeholder="Телефон получателя">
</div>
</div>
</div>
<!-- Крупный переключатель "Покупатель = получатель" -->
<div class="mb-3">
<div class="form-check form-switch" style="padding-left: 3.5em;">
<input class="form-check-input" type="checkbox" role="switch"
id="{{ form.customer_is_recipient.id_for_label }}"
name="{{ form.customer_is_recipient.name }}"
style="width: 3em; height: 1.5em; cursor: pointer;">
<label class="form-check-label" for="{{ form.customer_is_recipient.id_for_label }}"
style="font-size: 1.1em; font-weight: 500; cursor: pointer; padding-left: 0.5em;">
<i class="bi bi-person-check-fill text-primary"></i>
Покупатель является получателем Покупатель является получателем
</label> </label>
</div> </div>
</div>
<!-- Поля получателя (показываются когда покупатель != получатель) --> <!-- Поля получателя (показываются когда покупатель != получатель) -->
<div class="row" id="recipient-fields" style="display: none;"> <div class="row" id="recipient-fields" style="display: none;">
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="{{ form.recipient_name.id_for_label }}" class="form-label"> <label for="{{ form.recipient_name.id_for_label }}" class="form-label">
Имя получателя <span class="text-danger">*</span> Имя получателя
</label> </label>
{{ form.recipient_name }} {{ form.recipient_name }}
{% if form.recipient_name.errors %} {% if form.recipient_name.errors %}
@@ -334,7 +371,7 @@
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="{{ form.recipient_phone.id_for_label }}" class="form-label"> <label for="{{ form.recipient_phone.id_for_label }}" class="form-label">
Телефон получателя <span class="text-danger">*</span> Телефон получателя
</label> </label>
{{ form.recipient_phone }} {{ form.recipient_phone }}
{% if form.recipient_phone.errors %} {% if form.recipient_phone.errors %}
@@ -349,11 +386,17 @@
<div class="row mt-3"> <div class="row mt-3">
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
<label for="{{ form.delivery_cost.id_for_label }}" class="form-label">Стоимость доставки</label> <label for="{{ form.delivery_cost.id_for_label }}" class="form-label">
{{ form.delivery_cost.label }}
</label>
{{ form.delivery_cost }} {{ form.delivery_cost }}
{% if form.delivery_cost.errors %} {% if form.delivery_cost.errors %}
<div class="text-danger">{{ form.delivery_cost.errors }}</div> <div class="text-danger">{{ form.delivery_cost.errors }}</div>
{% endif %} {% endif %}
<small class="d-block text-muted mt-1">
<i class="bi bi-info-circle"></i>
Оставьте пустым для автоматического расчета (бесплатно от 100 руб., иначе 15 руб.)
</small>
</div> </div>
</div> </div>
</div> </div>
@@ -666,11 +709,19 @@ function initCustomerSelect2() {
// Обработчик для перехвата ПЕРЕД выбором (используется для фальшивых опций) // Обработчик для перехвата ПЕРЕД выбором (используется для фальшивых опций)
$customerSelect.on('select2:selecting', function(e) { $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; const data = e.params.data;
console.log('9a. Попытка выбрать элемент (перед выбором):', data); console.log('9b. Попытка выбрать элемент (перед выбором):', data);
if (data.is_create_option) { if (data.is_create_option) {
console.log('9b. Это опция создания клиента - предотвращаем выбор и открываем модаль'); console.log('9c. Это опция создания клиента - предотвращаем выбор и открываем модаль');
// Предотвращаем выбор этой опции // Предотвращаем выбор этой опции
e.preventDefault(); e.preventDefault();
// Очищаем значение // Очищаем значение
@@ -679,48 +730,21 @@ function initCustomerSelect2() {
window.openCreateCustomerModal(data.search_text); window.openCreateCustomerModal(data.search_text);
return false; return false;
} }
});
// Обработчик прямого клика на результаты (для "create option") console.log('9d. Обычный клиент, разрешаем выбор');
$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);
}); });
$customerSelect.on('select2:select', function(e) { $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; const data = e.params.data;
console.log('10. Выбран элемент:', data); console.log('10b. Выбран элемент:', data);
if (data.is_create_option) { if (data.is_create_option) {
console.log('11. Открываем модальное окно для создания клиента'); console.log('11. Открываем модальное окно для создания клиента');
@@ -731,7 +755,7 @@ function initCustomerSelect2() {
window.openCreateCustomerModal(data.search_text); window.openCreateCustomerModal(data.search_text);
} else { } else {
// Триггерим нативное change событие для других обработчиков (например, draft-creator.js) // Триггерим нативное change событие для других обработчиков (например, draft-creator.js)
console.log('12. Триггерим нативное change событие'); console.log('12. Триггерим нативное change событие для customer ID:', data.id);
const changeEvent = new Event('change', { bubbles: true }); const changeEvent = new Event('change', { bubbles: true });
this.dispatchEvent(changeEvent); this.dispatchEvent(changeEvent);
} }