From 6669d47cdfe0d2fd121298aab645db57b19865dc Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Tue, 23 Dec 2025 00:08:41 +0300 Subject: [PATCH] feat(orders): add recipient management and enhance order forms - Introduced Recipient model to manage order recipients separately from customers. - Updated Order model to link to Recipient, replacing recipient_name and recipient_phone fields. - Enhanced OrderForm to include recipient selection modes: customer, history, and new. - Added AJAX endpoint to fetch recipient history for customers. - Updated admin interface to manage recipients and display recipient information in order details. - Refactored address handling to accommodate new recipient logic. - Improved demo order creation to include random recipients. --- myproject/orders/admin.py | 43 +++++-- myproject/orders/forms.py | 67 +++++++++-- .../management/commands/create_demo_orders.py | 12 +- ..._remove_address_recipient_name_and_more.py | 69 +++++++++++ .../migrations/0011_migrate_recipient_data.py | 77 +++++++++++++ myproject/orders/models/__init__.py | 3 + myproject/orders/models/address.py | 28 +---- myproject/orders/models/order.py | 29 ++--- myproject/orders/models/recipient.py | 41 +++++++ myproject/orders/services/address_service.py | 94 +++++++++++++-- .../orders/templates/orders/order_detail.html | 6 +- .../orders/templates/orders/order_form.html | 64 +++++++---- myproject/orders/urls.py | 1 + myproject/orders/views.py | 107 +++++++++++++++--- myproject/products/tasks.py | 28 ++++- 15 files changed, 559 insertions(+), 110 deletions(-) create mode 100644 myproject/orders/migrations/0010_remove_address_recipient_name_and_more.py create mode 100644 myproject/orders/migrations/0011_migrate_recipient_data.py create mode 100644 myproject/orders/models/recipient.py diff --git a/myproject/orders/admin.py b/myproject/orders/admin.py index f0867d4..a21ddbf 100644 --- a/myproject/orders/admin.py +++ b/myproject/orders/admin.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from django.contrib import admin from django.utils.html import format_html -from .models import Order, OrderItem, Transaction, PaymentMethod, Address, OrderStatus +from .models import Order, OrderItem, Transaction, PaymentMethod, Address, OrderStatus, Recipient class TransactionInline(admin.TabularInline): @@ -61,7 +61,7 @@ class OrderAdmin(admin.ModelAdmin): 'customer__name', 'customer__phone', 'customer__email', - 'delivery_address__recipient_name', + 'recipient__name', 'delivery_address__street', ] @@ -240,8 +240,6 @@ class AddressAdmin(admin.ModelAdmin): Админ-панель для управления адресами доставки заказов. """ list_display = [ - 'recipient_name', - 'recipient_phone', 'full_address', 'entrance', 'floor', @@ -255,7 +253,6 @@ class AddressAdmin(admin.ModelAdmin): ] search_fields = [ - 'recipient_name', 'street', 'building_number', ] @@ -263,9 +260,6 @@ class AddressAdmin(admin.ModelAdmin): readonly_fields = ['created_at', 'updated_at'] fieldsets = ( - ('Информация о получателе', { - 'fields': ('recipient_name', 'recipient_phone') - }), ('Адрес доставки', { 'fields': ('street', 'building_number', 'apartment_number', 'entrance', 'floor') }), @@ -284,6 +278,39 @@ class AddressAdmin(admin.ModelAdmin): ) +@admin.register(Recipient) +class RecipientAdmin(admin.ModelAdmin): + """ + Админ-панель для управления получателями заказов. + """ + list_display = [ + 'name', + 'phone', + 'created_at', + ] + + list_filter = [ + 'created_at', + ] + + search_fields = [ + 'name', + 'phone', + ] + + readonly_fields = ['created_at', 'updated_at'] + + fieldsets = ( + ('Информация о получателе', { + 'fields': ('name', 'phone') + }), + ('Даты', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + @admin.register(OrderStatus) class OrderStatusAdmin(admin.ModelAdmin): """ diff --git a/myproject/orders/forms.py b/myproject/orders/forms.py index 839ff2f..021a2f6 100644 --- a/myproject/orders/forms.py +++ b/myproject/orders/forms.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from django import forms from django.forms import inlineformset_factory -from .models import Order, OrderItem, Transaction, Address, OrderStatus +from .models import Order, OrderItem, Transaction, Address, OrderStatus, Recipient from customers.models import Customer from inventory.models import Warehouse from products.models import Product, ProductKit @@ -11,7 +11,43 @@ from decimal import Decimal class OrderForm(forms.ModelForm): """Форма для создания и редактирования заказа""" - # Поля для ввода адреса + # Поля для работы с получателем + recipient_mode = forms.ChoiceField( + choices=[ + ('customer', 'Покупатель является получателем'), + ('history', 'Выбрать из истории'), + ('new', 'Другой получатель'), + ], + initial='customer', + widget=forms.RadioSelect(attrs={'class': 'form-check-input'}), + required=False, + label='Получатель' + ) + + # Выбор получателя из истории + recipient_from_history = forms.ModelChoiceField( + queryset=Recipient.objects.none(), + required=False, + widget=forms.Select(attrs={'class': 'form-select'}), + label='Получатель из истории' + ) + + # Поля для нового получателя + recipient_name = forms.CharField( + max_length=200, + required=False, + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Имя получателя'}), + label='Имя получателя' + ) + + recipient_phone = forms.CharField( + max_length=20, + required=False, + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Телефон получателя'}), + label='Телефон получателя' + ) + + # Поля для работы с адресом address_mode = forms.ChoiceField( choices=[ ('history', 'Выбрать из истории'), @@ -99,8 +135,7 @@ class OrderForm(forms.ModelForm): 'delivery_time_end', 'delivery_cost', 'customer_is_recipient', - 'recipient_name', - 'recipient_phone', + 'recipient', 'status', 'is_anonymous', 'special_instructions', @@ -186,15 +221,25 @@ class OrderForm(forms.ModelForm): self.fields['is_delivery'].label = 'С доставкой' self.fields['customer_is_recipient'].label = 'Покупатель = получатель' - # Поля получателя опциональны - self.fields['recipient_name'].required = False - self.fields['recipient_phone'].required = False + # Поле получателя опционально + self.fields['recipient'].required = False # Поле ручной стоимости доставки опционально self.fields['delivery_cost'].required = False self.fields['delivery_cost'].label = 'Ручная стоимость доставки' self.fields['delivery_cost'].help_text = 'Оставьте пустым для автоматического расчета' + # Инициализируем queryset для recipient_from_history + if self.instance.pk and self.instance.customer: + # При редактировании заказа загружаем историю получателей этого клиента + customer_orders = Order.objects.filter( + customer=self.instance.customer, + recipient__isnull=False + ).order_by('-created_at') + self.fields['recipient_from_history'].queryset = Recipient.objects.filter( + orders__in=customer_orders + ).distinct().order_by('-created_at') + # Инициализируем queryset для address_from_history # Это будет переопределено в представлении после выбора клиента if self.instance.pk and self.instance.customer: @@ -204,9 +249,15 @@ class OrderForm(forms.ModelForm): delivery_address__isnull=False ).order_by('-created_at') self.fields['address_from_history'].queryset = Address.objects.filter( - order__in=customer_orders + orders__in=customer_orders ).distinct().order_by('-created_at') + # Инициализируем поля получателя из существующего recipient + if self.instance.pk and self.instance.recipient: + recipient = self.instance.recipient + self.fields['recipient_name'].initial = recipient.name or '' + self.fields['recipient_phone'].initial = recipient.phone or '' + # Инициализируем поля адреса из существующего delivery_address if self.instance.pk and self.instance.delivery_address: address = self.instance.delivery_address diff --git a/myproject/orders/management/commands/create_demo_orders.py b/myproject/orders/management/commands/create_demo_orders.py index 01020a8..3924ff6 100644 --- a/myproject/orders/management/commands/create_demo_orders.py +++ b/myproject/orders/management/commands/create_demo_orders.py @@ -10,7 +10,7 @@ from datetime import datetime, timedelta import random from decimal import Decimal -from orders.models import Order, OrderItem, Address +from orders.models import Order, OrderItem, Address, Recipient from customers.models import Customer from inventory.models import Warehouse from products.models import Product @@ -133,8 +133,14 @@ class Command(BaseCommand): # Дополнительная информация if random.random() > 0.7: # 30% - подарок другому человеку order.customer_is_recipient = False - order.recipient_name = f"Получатель {i+1}" - order.recipient_phone = f"+7{random.randint(9000000000, 9999999999)}" + # Создаем получателя + recipient_name = f"Получатель {i+1}" + recipient_phone = f"+7{random.randint(9000000000, 9999999999)}" + recipient, created = Recipient.objects.get_or_create( + name=recipient_name, + phone=recipient_phone + ) + order.recipient = recipient if random.random() > 0.8: # 20% анонимных order.is_anonymous = True diff --git a/myproject/orders/migrations/0010_remove_address_recipient_name_and_more.py b/myproject/orders/migrations/0010_remove_address_recipient_name_and_more.py new file mode 100644 index 0000000..4c2ad57 --- /dev/null +++ b/myproject/orders/migrations/0010_remove_address_recipient_name_and_more.py @@ -0,0 +1,69 @@ +# Generated by Django 5.0.10 on 2025-12-22 19:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0009_add_original_product_to_kit_item_snapshot'), + ] + + operations = [ + migrations.RemoveField( + model_name='address', + name='recipient_name', + ), + migrations.RemoveField( + model_name='address', + name='recipient_phone', + ), + migrations.RemoveField( + model_name='historicalorder', + name='recipient_name', + ), + migrations.RemoveField( + model_name='historicalorder', + name='recipient_phone', + ), + migrations.RemoveField( + model_name='order', + name='recipient_name', + ), + migrations.RemoveField( + model_name='order', + name='recipient_phone', + ), + migrations.AlterField( + model_name='order', + name='delivery_address', + field=models.ForeignKey(blank=True, help_text='Обязательно для курьерской доставки', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='orders.address', verbose_name='Адрес доставки'), + ), + migrations.CreateModel( + name='Recipient', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='ФИО или название организации получателя', max_length=200, verbose_name='Имя получателя')), + ('phone', models.CharField(help_text='Контактный телефон для связи с получателем', max_length=20, verbose_name='Телефон получателя')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ], + options={ + 'verbose_name': 'Получатель', + 'verbose_name_plural': 'Получатели', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['phone'], name='orders_reci_phone_735356_idx'), models.Index(fields=['name'], name='orders_reci_name_e52d5b_idx'), models.Index(fields=['created_at'], name='orders_reci_created_34a391_idx')], + }, + ), + migrations.AddField( + model_name='historicalorder', + name='recipient', + field=models.ForeignKey(blank=True, db_constraint=False, help_text='Заполняется, если покупатель не является получателем', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='orders.recipient', verbose_name='Получатель'), + ), + migrations.AddField( + model_name='order', + name='recipient', + field=models.ForeignKey(blank=True, help_text='Заполняется, если покупатель не является получателем', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='orders.recipient', verbose_name='Получатель'), + ), + ] diff --git a/myproject/orders/migrations/0011_migrate_recipient_data.py b/myproject/orders/migrations/0011_migrate_recipient_data.py new file mode 100644 index 0000000..2962883 --- /dev/null +++ b/myproject/orders/migrations/0011_migrate_recipient_data.py @@ -0,0 +1,77 @@ +# Generated by Django 5.0.10 on 2025-12-22 19:32 + +from django.db import migrations + + +def migrate_recipient_data_forward(apps, schema_editor): + """ + Перенос данных получателей из старых полей Order в новую модель Recipient. + Так как поля recipient_name и recipient_phone уже удалены, + мы используем HistoricalOrder для восстановления данных. + """ + # Получаем модели + HistoricalOrder = apps.get_model('orders', 'HistoricalOrder') + Recipient = apps.get_model('orders', 'Recipient') + Order = apps.get_model('orders', 'Order') + + # Словарь для кэширования recipient'ов + recipients_cache = {} + + # Обрабатываем каждый заказ + for order in Order.objects.all(): + # Находим последнюю историческую запись для этого заказа + hist = HistoricalOrder.objects.filter( + order_number=order.order_number + ).order_by('-history_date').first() + + if not hist: + continue + + # Проверяем, есть ли данные получателя + recipient_name = getattr(hist, 'recipient_name', None) + recipient_phone = getattr(hist, 'recipient_phone', None) + + # Если получатель не указан или customer_is_recipient=True, пропускаем + if not recipient_name or not recipient_phone or order.customer_is_recipient: + continue + + # Создаем ключ для кэша + cache_key = f"{recipient_name}|{recipient_phone}" + + # Проверяем, есть ли уже такой получатель в кэше + if cache_key in recipients_cache: + recipient = recipients_cache[cache_key] + else: + # Создаем нового получателя + recipient, created = Recipient.objects.get_or_create( + name=recipient_name, + phone=recipient_phone + ) + recipients_cache[cache_key] = recipient + + # Привязываем получателя к заказу + order.recipient = recipient + order.save(update_fields=['recipient']) + + +def migrate_recipient_data_backward(apps, schema_editor): + """ + Обратная миграция - просто очищаем recipient поле в Order. + Данные вернутся из HistoricalOrder при повторном apply. + """ + Order = apps.get_model('orders', 'Order') + Order.objects.all().update(recipient=None) + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0010_remove_address_recipient_name_and_more'), + ] + + operations = [ + migrations.RunPython( + migrate_recipient_data_forward, + migrate_recipient_data_backward + ), + ] diff --git a/myproject/orders/models/__init__.py b/myproject/orders/models/__init__.py index e93ed7c..a97987f 100644 --- a/myproject/orders/models/__init__.py +++ b/myproject/orders/models/__init__.py @@ -4,6 +4,7 @@ Структура: - OrderStatus: Статусы заказов - Address: Адреса доставки +- Recipient: Получатели заказов - Order: Главная модель заказа - OrderItem: Позиции в заказе - PaymentMethod: Способы оплаты (справочник) @@ -17,6 +18,7 @@ from .payment_method import PaymentMethod # 2. Модели с зависимостями от справочников from .address import Address +from .recipient import Recipient # 3. Главная модель Order (зависит от Status, Address) from .order import Order @@ -29,6 +31,7 @@ from .transaction import Transaction __all__ = [ 'OrderStatus', 'Address', + 'Recipient', 'Order', 'OrderItem', 'PaymentMethod', diff --git a/myproject/orders/models/address.py b/myproject/orders/models/address.py index c5a17a9..64722a6 100644 --- a/myproject/orders/models/address.py +++ b/myproject/orders/models/address.py @@ -3,26 +3,9 @@ from django.db import models class Address(models.Model): """ - Модель адреса доставки для заказа цветочного магазина в Минске. - Адрес принадлежит конкретному заказу доставки. + Модель адреса доставки. + Адрес может использоваться в разных заказах и для разных получателей. """ - # Информация о получателе - 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="Контактный телефон получателя для уточнения адреса" - ) - street = models.CharField( max_length=255, blank=True, @@ -103,12 +86,7 @@ class Address(models.Model): if 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 address_line + return ", ".join(address_parts) if address_parts else "Адрес не указан" @property def full_address(self): diff --git a/myproject/orders/models/order.py b/myproject/orders/models/order.py index a913779..2494472 100644 --- a/myproject/orders/models/order.py +++ b/myproject/orders/models/order.py @@ -6,6 +6,7 @@ from inventory.models import Warehouse from simple_history.models import HistoricalRecords from .status import OrderStatus from .address import Address +from .recipient import Recipient class Order(models.Model): @@ -38,12 +39,12 @@ class Order(models.Model): ) # Адрес доставки (для курьерской доставки) - delivery_address = models.OneToOneField( + delivery_address = models.ForeignKey( Address, - on_delete=models.CASCADE, + on_delete=models.SET_NULL, null=True, blank=True, - related_name='order', + related_name='orders', verbose_name="Адрес доставки", help_text="Обязательно для курьерской доставки" ) @@ -160,30 +161,24 @@ class Order(models.Model): help_text="Обновляется автоматически при добавлении платежей" ) - # Дополнительная информация + # Информация о получателе customer_is_recipient = models.BooleanField( default=True, verbose_name="Покупатель является получателем", help_text="Если отмечено, данные получателя не требуются отдельно" ) - # Данные получателя (если покупатель != получатель) - recipient_name = models.CharField( - max_length=200, - blank=True, + # Получатель (если покупатель != получатель) + recipient = models.ForeignKey( + Recipient, + on_delete=models.SET_NULL, null=True, - verbose_name="Имя получателя", + blank=True, + related_name='orders', + verbose_name="Получатель", help_text="Заполняется, если покупатель не является получателем" ) - recipient_phone = models.CharField( - max_length=20, - blank=True, - null=True, - verbose_name="Телефон получателя", - help_text="Контактный телефон получателя" - ) - is_anonymous = models.BooleanField( default=False, verbose_name="Анонимная доставка", diff --git a/myproject/orders/models/recipient.py b/myproject/orders/models/recipient.py new file mode 100644 index 0000000..df03746 --- /dev/null +++ b/myproject/orders/models/recipient.py @@ -0,0 +1,41 @@ +from django.db import models + + +class Recipient(models.Model): + """ + Модель получателя заказа. + Один получатель может получать доставки по разным адресам. + """ + name = models.CharField( + max_length=200, + verbose_name="Имя получателя", + help_text="ФИО или название организации получателя" + ) + + phone = models.CharField( + max_length=20, + verbose_name="Телефон получателя", + help_text="Контактный телефон для связи с получателем" + ) + + # Временные метки + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") + updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления") + + class Meta: + verbose_name = "Получатель" + verbose_name_plural = "Получатели" + indexes = [ + models.Index(fields=['phone']), + models.Index(fields=['name']), + models.Index(fields=['created_at']), + ] + ordering = ['-created_at'] + + def __str__(self): + return f"{self.name} ({self.phone})" + + @property + def display_name(self): + """Форматированное имя для отображения""" + return f"{self.name} - {self.phone}" diff --git a/myproject/orders/services/address_service.py b/myproject/orders/services/address_service.py index 53e3855..71c45e2 100644 --- a/myproject/orders/services/address_service.py +++ b/myproject/orders/services/address_service.py @@ -1,12 +1,12 @@ """ -Сервис для работы с адресами заказов. -Содержит логику создания, обновления и управления адресами доставки. +Сервис для работы с адресами и получателями заказов. +Содержит логику создания, обновления и управления адресами доставки и получателями. """ -from ..models import Order, Address +from ..models import Order, Address, Recipient class AddressService: - """Сервис для управления адресами доставки в заказах""" + """Сервис для управления адресами доставки и получателями в заказах""" @staticmethod def create_address_from_form_data(form_data): @@ -27,8 +27,6 @@ class AddressService: Address: Новый объект адреса (не сохраненный в БД) """ address = Address( - recipient_name=form_data.get('recipient_name', ''), - recipient_phone=form_data.get('recipient_phone', ''), street=form_data.get('address_street', ''), building_number=form_data.get('address_building_number', ''), apartment_number=form_data.get('address_apartment_number', ''), @@ -40,6 +38,76 @@ class AddressService: ) return address + @staticmethod + def create_recipient_from_form_data(form_data): + """ + Создает объект Recipient из данных формы. + + Args: + form_data (dict): Словарь с данными из формы + - recipient_name + - recipient_phone + + Returns: + Recipient: Новый объект получателя (не сохраненный в БД) + """ + recipient = Recipient( + name=form_data.get('recipient_name', ''), + phone=form_data.get('recipient_phone', ''), + ) + return recipient + + @staticmethod + def process_recipient_from_form(order, form_data): + """ + Обрабатывает получателя из данных формы заказа. + Создает нового Recipient или использует существующего в зависимости от режима. + + Args: + order (Order): Объект заказа + form_data (dict): Все данные из формы + + Returns: + Recipient or None: Получатель для привязки к заказу или None + """ + recipient_mode = form_data.get('recipient_mode') + + # Если режим "покупатель = получатель" - возвращаем None + if recipient_mode == 'customer': + return None + + # Если режим "выбрать из истории" - возвращаем выбранного получателя + if recipient_mode == 'history': + recipient_id = form_data.get('recipient_from_history') + if recipient_id: + try: + return Recipient.objects.get(pk=recipient_id) + except Recipient.DoesNotExist: + return None + + # Если режим "новый получатель" + if recipient_mode == 'new': + name = form_data.get('recipient_name', '').strip() + phone = form_data.get('recipient_phone', '').strip() + + if not name or not phone: + return None + + # Проверяем, есть ли уже такой получатель в БД + existing_recipient = Recipient.objects.filter( + name=name, + phone=phone + ).first() + + if existing_recipient: + return existing_recipient + + # Создаем нового получателя + recipient = AddressService.create_recipient_from_form_data(form_data) + return recipient + + return None + @staticmethod def process_address_from_form(order, form_data): """ @@ -90,7 +158,17 @@ class AddressService: # Если все поля адреса пустые, возвращаем None return None - # Создаем новый адрес (даже если не все обязательные поля заполнены) + # Проверяем, есть ли уже такой адрес в БД (по основным полям) + existing_address = Address.objects.filter( + street=street, + building_number=building_number, + apartment_number=form_data.get('address_apartment_number', '').strip() + ).first() + + if existing_address: + return existing_address + + # Создаем новый адрес address = AddressService.create_address_from_form_data(form_data) return address @@ -113,7 +191,7 @@ class AddressService: ).order_by('-created_at') addresses = Address.objects.filter( - order__in=customer_orders + orders__in=customer_orders ).distinct().order_by('-created_at') return addresses diff --git a/myproject/orders/templates/orders/order_detail.html b/myproject/orders/templates/orders/order_detail.html index 58faccb..dcb04d3 100644 --- a/myproject/orders/templates/orders/order_detail.html +++ b/myproject/orders/templates/orders/order_detail.html @@ -131,7 +131,7 @@ - {% if not order.customer_is_recipient %} + {% if not order.customer_is_recipient and order.recipient %}
Получатель
@@ -139,11 +139,11 @@
Имя получателя:
-
{{ order.recipient_name|default:"Не указано" }}
+
{{ order.recipient.name|default:"Не указано" }}
Телефон получателя:
-
{{ order.recipient_phone|default:"Не указан" }}
+
{{ order.recipient.phone|default:"Не указан" }}
{% if order.is_anonymous %}
diff --git a/myproject/orders/templates/orders/order_form.html b/myproject/orders/templates/orders/order_form.html index b706ffb..d65997b 100644 --- a/myproject/orders/templates/orders/order_form.html +++ b/myproject/orders/templates/orders/order_form.html @@ -504,24 +504,34 @@
Получатель
- +
-
- -
- -