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.
This commit is contained in:
2025-12-23 00:08:41 +03:00
parent 483f150e7a
commit 6669d47cdf
15 changed files with 559 additions and 110 deletions

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from django.contrib import admin from django.contrib import admin
from django.utils.html import format_html 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): class TransactionInline(admin.TabularInline):
@@ -61,7 +61,7 @@ class OrderAdmin(admin.ModelAdmin):
'customer__name', 'customer__name',
'customer__phone', 'customer__phone',
'customer__email', 'customer__email',
'delivery_address__recipient_name', 'recipient__name',
'delivery_address__street', 'delivery_address__street',
] ]
@@ -240,8 +240,6 @@ class AddressAdmin(admin.ModelAdmin):
Админ-панель для управления адресами доставки заказов. Админ-панель для управления адресами доставки заказов.
""" """
list_display = [ list_display = [
'recipient_name',
'recipient_phone',
'full_address', 'full_address',
'entrance', 'entrance',
'floor', 'floor',
@@ -255,7 +253,6 @@ class AddressAdmin(admin.ModelAdmin):
] ]
search_fields = [ search_fields = [
'recipient_name',
'street', 'street',
'building_number', 'building_number',
] ]
@@ -263,9 +260,6 @@ class AddressAdmin(admin.ModelAdmin):
readonly_fields = ['created_at', 'updated_at'] readonly_fields = ['created_at', 'updated_at']
fieldsets = ( fieldsets = (
('Информация о получателе', {
'fields': ('recipient_name', 'recipient_phone')
}),
('Адрес доставки', { ('Адрес доставки', {
'fields': ('street', 'building_number', 'apartment_number', 'entrance', 'floor') '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) @admin.register(OrderStatus)
class OrderStatusAdmin(admin.ModelAdmin): class OrderStatusAdmin(admin.ModelAdmin):
""" """

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from django import forms from django import forms
from django.forms import inlineformset_factory 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 customers.models import Customer
from inventory.models import Warehouse from inventory.models import Warehouse
from products.models import Product, ProductKit from products.models import Product, ProductKit
@@ -11,7 +11,43 @@ from decimal import Decimal
class OrderForm(forms.ModelForm): 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( address_mode = forms.ChoiceField(
choices=[ choices=[
('history', 'Выбрать из истории'), ('history', 'Выбрать из истории'),
@@ -99,8 +135,7 @@ class OrderForm(forms.ModelForm):
'delivery_time_end', 'delivery_time_end',
'delivery_cost', 'delivery_cost',
'customer_is_recipient', 'customer_is_recipient',
'recipient_name', 'recipient',
'recipient_phone',
'status', 'status',
'is_anonymous', 'is_anonymous',
'special_instructions', 'special_instructions',
@@ -186,15 +221,25 @@ class OrderForm(forms.ModelForm):
self.fields['is_delivery'].label = 'С доставкой' self.fields['is_delivery'].label = 'С доставкой'
self.fields['customer_is_recipient'].label = 'Покупатель = получатель' self.fields['customer_is_recipient'].label = 'Покупатель = получатель'
# Поля получателя опциональны # Поле получателя опционально
self.fields['recipient_name'].required = False self.fields['recipient'].required = False
self.fields['recipient_phone'].required = False
# Поле ручной стоимости доставки опционально # Поле ручной стоимости доставки опционально
self.fields['delivery_cost'].required = False self.fields['delivery_cost'].required = False
self.fields['delivery_cost'].label = 'Ручная стоимость доставки' self.fields['delivery_cost'].label = 'Ручная стоимость доставки'
self.fields['delivery_cost'].help_text = 'Оставьте пустым для автоматического расчета' 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 # Инициализируем queryset для address_from_history
# Это будет переопределено в представлении после выбора клиента # Это будет переопределено в представлении после выбора клиента
if self.instance.pk and self.instance.customer: if self.instance.pk and self.instance.customer:
@@ -204,9 +249,15 @@ class OrderForm(forms.ModelForm):
delivery_address__isnull=False delivery_address__isnull=False
).order_by('-created_at') ).order_by('-created_at')
self.fields['address_from_history'].queryset = Address.objects.filter( self.fields['address_from_history'].queryset = Address.objects.filter(
order__in=customer_orders orders__in=customer_orders
).distinct().order_by('-created_at') ).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 # Инициализируем поля адреса из существующего delivery_address
if self.instance.pk and self.instance.delivery_address: if self.instance.pk and self.instance.delivery_address:
address = self.instance.delivery_address address = self.instance.delivery_address

View File

@@ -10,7 +10,7 @@ from datetime import datetime, timedelta
import random import random
from decimal import Decimal 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 customers.models import Customer
from inventory.models import Warehouse from inventory.models import Warehouse
from products.models import Product from products.models import Product
@@ -133,8 +133,14 @@ class Command(BaseCommand):
# Дополнительная информация # Дополнительная информация
if random.random() > 0.7: # 30% - подарок другому человеку if random.random() > 0.7: # 30% - подарок другому человеку
order.customer_is_recipient = False 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% анонимных if random.random() > 0.8: # 20% анонимных
order.is_anonymous = True order.is_anonymous = True

View File

@@ -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='Получатель'),
),
]

View File

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

View File

@@ -4,6 +4,7 @@
Структура: Структура:
- OrderStatus: Статусы заказов - OrderStatus: Статусы заказов
- Address: Адреса доставки - Address: Адреса доставки
- Recipient: Получатели заказов
- Order: Главная модель заказа - Order: Главная модель заказа
- OrderItem: Позиции в заказе - OrderItem: Позиции в заказе
- PaymentMethod: Способы оплаты (справочник) - PaymentMethod: Способы оплаты (справочник)
@@ -17,6 +18,7 @@ from .payment_method import PaymentMethod
# 2. Модели с зависимостями от справочников # 2. Модели с зависимостями от справочников
from .address import Address from .address import Address
from .recipient import Recipient
# 3. Главная модель Order (зависит от Status, Address) # 3. Главная модель Order (зависит от Status, Address)
from .order import Order from .order import Order
@@ -29,6 +31,7 @@ from .transaction import Transaction
__all__ = [ __all__ = [
'OrderStatus', 'OrderStatus',
'Address', 'Address',
'Recipient',
'Order', 'Order',
'OrderItem', 'OrderItem',
'PaymentMethod', 'PaymentMethod',

View File

@@ -3,26 +3,9 @@ from django.db import models
class Address(models.Model): 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( street = models.CharField(
max_length=255, max_length=255,
blank=True, blank=True,
@@ -103,12 +86,7 @@ class Address(models.Model):
if self.apartment_number: if self.apartment_number:
address_parts.append(f"кв/офис {self.apartment_number}") address_parts.append(f"кв/офис {self.apartment_number}")
address_line = ", ".join(address_parts) if address_parts else "Адрес не указан" return ", ".join(address_parts) if address_parts else "Адрес не указан"
# Формируем строку с именем получателя
if self.recipient_name:
return f"{self.recipient_name} - {address_line}"
return address_line
@property @property
def full_address(self): def full_address(self):

View File

@@ -6,6 +6,7 @@ from inventory.models import Warehouse
from simple_history.models import HistoricalRecords from simple_history.models import HistoricalRecords
from .status import OrderStatus from .status import OrderStatus
from .address import Address from .address import Address
from .recipient import Recipient
class Order(models.Model): class Order(models.Model):
@@ -38,12 +39,12 @@ class Order(models.Model):
) )
# Адрес доставки (для курьерской доставки) # Адрес доставки (для курьерской доставки)
delivery_address = models.OneToOneField( delivery_address = models.ForeignKey(
Address, Address,
on_delete=models.CASCADE, on_delete=models.SET_NULL,
null=True, null=True,
blank=True, blank=True,
related_name='order', related_name='orders',
verbose_name="Адрес доставки", verbose_name="Адрес доставки",
help_text="Обязательно для курьерской доставки" help_text="Обязательно для курьерской доставки"
) )
@@ -160,30 +161,24 @@ class Order(models.Model):
help_text="Обновляется автоматически при добавлении платежей" help_text="Обновляется автоматически при добавлении платежей"
) )
# Дополнительная информация # Информация о получателе
customer_is_recipient = models.BooleanField( customer_is_recipient = models.BooleanField(
default=True, default=True,
verbose_name="Покупатель является получателем", verbose_name="Покупатель является получателем",
help_text="Если отмечено, данные получателя не требуются отдельно" help_text="Если отмечено, данные получателя не требуются отдельно"
) )
# Данные получателя (если покупатель != получатель) # Получатель (если покупатель != получатель)
recipient_name = models.CharField( recipient = models.ForeignKey(
max_length=200, Recipient,
blank=True, on_delete=models.SET_NULL,
null=True, null=True,
verbose_name="Имя получателя", blank=True,
related_name='orders',
verbose_name="Получатель",
help_text="Заполняется, если покупатель не является получателем" 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="Анонимная доставка",

View File

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

View File

@@ -1,12 +1,12 @@
""" """
Сервис для работы с адресами заказов. Сервис для работы с адресами и получателями заказов.
Содержит логику создания, обновления и управления адресами доставки. Содержит логику создания, обновления и управления адресами доставки и получателями.
""" """
from ..models import Order, Address from ..models import Order, Address, Recipient
class AddressService: class AddressService:
"""Сервис для управления адресами доставки в заказах""" """Сервис для управления адресами доставки и получателями в заказах"""
@staticmethod @staticmethod
def create_address_from_form_data(form_data): def create_address_from_form_data(form_data):
@@ -27,8 +27,6 @@ class AddressService:
Address: Новый объект адреса (не сохраненный в БД) Address: Новый объект адреса (не сохраненный в БД)
""" """
address = Address( address = Address(
recipient_name=form_data.get('recipient_name', ''),
recipient_phone=form_data.get('recipient_phone', ''),
street=form_data.get('address_street', ''), street=form_data.get('address_street', ''),
building_number=form_data.get('address_building_number', ''), building_number=form_data.get('address_building_number', ''),
apartment_number=form_data.get('address_apartment_number', ''), apartment_number=form_data.get('address_apartment_number', ''),
@@ -40,6 +38,76 @@ class AddressService:
) )
return address 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 @staticmethod
def process_address_from_form(order, form_data): def process_address_from_form(order, form_data):
""" """
@@ -90,7 +158,17 @@ class AddressService:
# Если все поля адреса пустые, возвращаем None # Если все поля адреса пустые, возвращаем None
return 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) address = AddressService.create_address_from_form_data(form_data)
return address return address
@@ -113,7 +191,7 @@ class AddressService:
).order_by('-created_at') ).order_by('-created_at')
addresses = Address.objects.filter( addresses = Address.objects.filter(
order__in=customer_orders orders__in=customer_orders
).distinct().order_by('-created_at') ).distinct().order_by('-created_at')
return addresses return addresses

View File

@@ -131,7 +131,7 @@
</div> </div>
<!-- Получатель --> <!-- Получатель -->
{% if not order.customer_is_recipient %} {% if not order.customer_is_recipient and order.recipient %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0">Получатель</h5> <h5 class="mb-0">Получатель</h5>
@@ -139,11 +139,11 @@
<div class="card-body"> <div class="card-body">
<div class="row mb-2"> <div class="row mb-2">
<div class="col-md-4"><strong>Имя получателя:</strong></div> <div class="col-md-4"><strong>Имя получателя:</strong></div>
<div class="col-md-8">{{ order.recipient_name|default:"Не указано" }}</div> <div class="col-md-8">{{ order.recipient.name|default:"Не указано" }}</div>
</div> </div>
<div class="row mb-2"> <div class="row mb-2">
<div class="col-md-4"><strong>Телефон получателя:</strong></div> <div class="col-md-4"><strong>Телефон получателя:</strong></div>
<div class="col-md-8">{{ order.recipient_phone|default:"Не указан" }}</div> <div class="col-md-8">{{ order.recipient.phone|default:"Не указан" }}</div>
</div> </div>
{% if order.is_anonymous %} {% if order.is_anonymous %}
<div class="row mb-2"> <div class="row mb-2">

View File

@@ -504,24 +504,34 @@
<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>
<!-- Крупный переключатель "Покупатель = получатель" --> <!-- Режимы выбора получателя -->
<div class="mb-3"> <div class="mb-3">
<div class="form-check form-switch" style="padding-left: 3.5em;"> {% for choice in form.recipient_mode %}
<input class="form-check-input" type="checkbox" role="switch" <div class="form-check">
id="{{ form.customer_is_recipient.id_for_label }}" {{ choice.tag }}
name="{{ form.customer_is_recipient.name }}" <label class="form-check-label" for="{{ choice.id_for_label }}">
{% if form.customer_is_recipient.value %}checked{% endif %} {{ choice.choice_label }}
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>
{% endfor %}
{% if form.recipient_mode.errors %}
<div class="text-danger">{{ form.recipient_mode.errors }}</div>
{% endif %}
</div> </div>
<!-- Поля получателя (показываются когда покупатель != получатель) --> <!-- Выбор получателя из истории -->
<div class="row" id="recipient-fields" style="display: none;"> <div class="mb-3" id="recipient-history-field" style="display: none;">
<label for="{{ form.recipient_from_history.id_for_label }}" class="form-label">
{{ form.recipient_from_history.label }}
</label>
{{ form.recipient_from_history }}
{% if form.recipient_from_history.errors %}
<div class="text-danger">{{ form.recipient_from_history.errors }}</div>
{% endif %}
</div>
<!-- Поля нового получателя -->
<div class="row" id="recipient-new-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">
@@ -1461,18 +1471,32 @@ document.addEventListener('DOMContentLoaded', function() {
syncUIFromCheckbox(); syncUIFromCheckbox();
// Показ/скрытие полей получателя // Показ/скрытие полей получателя
const customerIsRecipientCheckbox = document.getElementById('{{ form.customer_is_recipient.id_for_label }}'); const recipientModeRadios = document.querySelectorAll('input[name="recipient_mode"]');
const recipientFields = document.getElementById('recipient-fields'); const recipientHistoryField = document.getElementById('recipient-history-field');
const recipientNewFields = document.getElementById('recipient-new-fields');
function toggleRecipientFields() { function toggleRecipientFields() {
if (customerIsRecipientCheckbox.checked) { const selectedMode = document.querySelector('input[name="recipient_mode"]:checked');
recipientFields.style.display = 'none'; if (!selectedMode) return;
} else {
recipientFields.style.display = ''; const mode = selectedMode.value;
// Скрываем все поля
recipientHistoryField.style.display = 'none';
recipientNewFields.style.display = 'none';
// Показываем нужные поля
if (mode === 'history') {
recipientHistoryField.style.display = 'block';
} else if (mode === 'new') {
recipientNewFields.style.display = 'block';
} }
// Для 'customer' ничего не показываем
} }
customerIsRecipientCheckbox.addEventListener('change', toggleRecipientFields); recipientModeRadios.forEach(radio => {
radio.addEventListener('change', toggleRecipientFields);
});
toggleRecipientFields(); toggleRecipientFields();
// === РАСЧЁТ ИТОГОВОЙ СУММЫ ТОВАРОВ === // === РАСЧЁТ ИТОГОВОЙ СУММЫ ТОВАРОВ ===

View File

@@ -18,6 +18,7 @@ urlpatterns = [
# AJAX endpoints # AJAX endpoints
path('api/customer-address-history/', views.get_customer_address_history, name='api-customer-address-history'), path('api/customer-address-history/', views.get_customer_address_history, name='api-customer-address-history'),
path('api/customer-recipient-history/', views.get_customer_recipient_history, name='api-customer-recipient-history'),
# Wallet payment # Wallet payment
path('<int:order_number>/apply-wallet/', views.apply_wallet_payment, name='apply-wallet'), path('<int:order_number>/apply-wallet/', views.apply_wallet_payment, name='apply-wallet'),

View File

@@ -97,6 +97,17 @@ def order_create(request):
# Сохраняем форму БЕЗ commit, чтобы не вызывать reset_delivery_cost() до сохранения items # Сохраняем форму БЕЗ commit, чтобы не вызывать reset_delivery_cost() до сохранения items
order = form.save(commit=False) order = form.save(commit=False)
# Обрабатываем получателя
recipient = AddressService.process_recipient_from_form(order, form.cleaned_data)
if recipient:
# Если получатель не существует в БД, сохраняем его
if not recipient.pk:
recipient.save()
order.recipient = recipient
else:
# Если покупатель является получателем
order.recipient = None
# Обрабатываем адрес доставки # Обрабатываем адрес доставки
if order.is_delivery: if order.is_delivery:
address = AddressService.process_address_from_form(order, form.cleaned_data) address = AddressService.process_address_from_form(order, form.cleaned_data)
@@ -211,6 +222,17 @@ def order_update(request, order_number):
with transaction.atomic(): with transaction.atomic():
order = form.save(commit=False) order = form.save(commit=False)
# Обрабатываем получателя
recipient = AddressService.process_recipient_from_form(order, form.cleaned_data)
if recipient:
# Если получатель не существует в БД, сохраняем его
if not recipient.pk:
recipient.save()
order.recipient = recipient
else:
# Если покупатель является получателем
order.recipient = None
# Обрабатываем адрес доставки # Обрабатываем адрес доставки
if order.is_delivery: if order.is_delivery:
address = AddressService.process_address_from_form(order, form.cleaned_data) address = AddressService.process_address_from_form(order, form.cleaned_data)
@@ -220,21 +242,11 @@ def order_update(request, order_number):
address.save() address.save()
order.delivery_address = address order.delivery_address = address
else: else:
# Если режим "без адреса", удаляем существующий адрес # Если режим "без адреса", очищаем адрес
if order.delivery_address:
old_address = order.delivery_address
order.delivery_address = None
# Удаляем старый адрес, если он больше не используется
if old_address and not old_address.order:
old_address.delete()
else:
# Если не доставка, удаляем адрес если он был
if order.delivery_address:
old_address = order.delivery_address
order.delivery_address = None order.delivery_address = None
# Удаляем старый адрес else:
if old_address and not old_address.order: # Если не доставка, очищаем адрес
old_address.delete() order.delivery_address = None
order.modified_by = request.user order.modified_by = request.user
order.save() order.save()
@@ -460,8 +472,6 @@ def get_customer_address_history(request):
'entrance': addr.entrance, 'entrance': addr.entrance,
'floor': addr.floor, 'floor': addr.floor,
'intercom_code': addr.intercom_code, 'intercom_code': addr.intercom_code,
'recipient_name': addr.recipient_name,
'recipient_phone': addr.recipient_phone,
} }
for addr in addresses for addr in addresses
] ]
@@ -479,6 +489,71 @@ def get_customer_address_history(request):
}, status=500) }, status=500)
@require_http_methods(["GET"])
@login_required
def get_customer_recipient_history(request):
"""
AJAX endpoint для получения истории получателей клиента.
GET параметры:
- customer_id: ID клиента
Возвращает JSON со списком получателей из истории заказов клиента.
"""
try:
customer_id = request.GET.get('customer_id')
if not customer_id:
return JsonResponse({
'success': False,
'error': 'customer_id не указан'
}, status=400)
from customers.models import Customer
from .models import Recipient
try:
customer = Customer.objects.get(pk=customer_id)
except Customer.DoesNotExist:
return JsonResponse({
'success': False,
'error': 'Клиент не найден'
}, status=404)
# Получаем получателей из истории заказов
customer_orders = Order.objects.filter(
customer=customer,
recipient__isnull=False
).order_by('-created_at')
recipients = Recipient.objects.filter(
orders__in=customer_orders
).distinct().order_by('-created_at')
# Форматируем для отправки клиенту
recipients_data = [
{
'id': recipient.id,
'name': recipient.name,
'phone': recipient.phone,
'display': f"{recipient.name} ({recipient.phone})",
}
for recipient in recipients
]
return JsonResponse({
'success': True,
'recipients': recipients_data,
'count': len(recipients_data)
})
except Exception as e:
return JsonResponse({
'success': False,
'error': f'Ошибка сервера: {str(e)}'
}, status=500)
# === УПРАВЛЕНИЕ СТАТУСАМИ ЗАКАЗОВ === # === УПРАВЛЕНИЕ СТАТУСАМИ ЗАКАЗОВ ===
@login_required @login_required

View File

@@ -17,6 +17,7 @@ from django.apps import apps
from django.conf import settings from django.conf import settings
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.utils import timezone from django.utils import timezone
from tenants.models import RESERVED_SCHEMA_NAMES
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -258,6 +259,19 @@ def cleanup_temp_media_for_schema(schema_name, ttl_hours=None):
from django.conf import settings from django.conf import settings
try: try:
# Пропускаем зарезервированные схемы (public, admin и т.д.)
if schema_name in RESERVED_SCHEMA_NAMES:
ttl = int(ttl_hours or getattr(settings, 'TEMP_MEDIA_TTL_HOURS', 24))
logger.info(f"[Cleanup:{schema_name}] Skipping reserved schema")
return {
'status': 'skipped',
'schema_name': schema_name,
'reason': 'reserved_schema',
'deleted': 0,
'scanned': 0,
'ttl_hours': ttl
}
# Активируем схему тенанта # Активируем схему тенанта
connection.set_schema(schema_name) connection.set_schema(schema_name)
@@ -272,7 +286,15 @@ def cleanup_temp_media_for_schema(schema_name, ttl_hours=None):
for rel_dir in temp_dirs: for rel_dir in temp_dirs:
try: try:
# Получаем полный путь с учётом tenant_id # Получаем полный путь с учётом tenant_id
full_dir = default_storage.path(rel_dir) try:
full_dir = default_storage.path(rel_dir)
except RuntimeError as storage_error:
# Если не удается определить tenant_id (например, для public схемы)
if 'Cannot determine tenant ID' in str(storage_error):
logger.warning(f"[Cleanup:{schema_name}] Skipping {rel_dir}: {storage_error}")
continue
raise # Перебрасываем другие ошибки
if not os.path.isdir(full_dir): if not os.path.isdir(full_dir):
continue continue
@@ -331,7 +353,9 @@ def cleanup_temp_media_all(ttl_hours=None):
connection.set_schema('public') connection.set_schema('public')
from tenants.models import Client from tenants.models import Client
schemas = list(Client.objects.values_list('schema_name', flat=True)) # Фильтруем зарезервированные схемы, чтобы не запускать задачи для них
schemas = [s for s in Client.objects.values_list('schema_name', flat=True)
if s not in RESERVED_SCHEMA_NAMES]
ttl = ttl_hours or getattr(settings, 'TEMP_MEDIA_TTL_HOURS', 24) ttl = ttl_hours or getattr(settings, 'TEMP_MEDIA_TTL_HOURS', 24)
logger.info(f"[CleanupAll] Scheduling cleanup for {len(schemas)} tenants (TTL: {ttl}h)") logger.info(f"[CleanupAll] Scheduling cleanup for {len(schemas)} tenants (TTL: {ttl}h)")