Simplify order creation and editing - remove autosave
- Removed autosave.js (665 lines) and draft-creator.js (441 lines) - Removed draft_service.py (~500 lines) and DraftOrderService - Removed AJAX endpoints: autosave and create-draft - Updated order_create() to add is_create_page flag - Updated order_update() to finalize drafts without DraftOrderService - Added get_new_status() method to OrderStatusService - Updated order_form.html: - Removed old JS includes - Added beforeunload warning for unsaved data - Updated buttons: separate buttons for create/draft/finalize - Total code reduction: ~1600 lines (92% removed) New workflow: - /orders/create/ - user fills form, chooses button - /orders/<id>/edit/ - simple editing without autosave - beforeunload warning when leaving page (except on submit)
This commit is contained in:
@@ -1,7 +1,5 @@
|
||||
"""
|
||||
!5@28A=K9 A;>9 4;O ?@8;>65=8O orders.
|
||||
Сервисный слой для приложения orders.
|
||||
"""
|
||||
|
||||
from .draft_service import DraftOrderService
|
||||
|
||||
__all__ = ['DraftOrderService']
|
||||
__all__ = []
|
||||
|
||||
@@ -1,564 +0,0 @@
|
||||
"""
|
||||
Сервис для работы с черновиками заказов.
|
||||
Содержит бизнес-логику создания, обновления и завершения черновиков.
|
||||
"""
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
from decimal import Decimal
|
||||
import decimal
|
||||
from datetime import datetime, date, time
|
||||
|
||||
from ..models import Order, OrderItem, Address
|
||||
from products.models import Product, ProductKit
|
||||
from .address_service import AddressService
|
||||
|
||||
|
||||
class DraftOrderService:
|
||||
"""
|
||||
Сервис для управления черновиками заказов.
|
||||
Обеспечивает создание, обновление и финализацию черновиков.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def create_draft(user, customer, data=None):
|
||||
"""
|
||||
Создает новый черновик заказа.
|
||||
|
||||
Args:
|
||||
user: Пользователь, создающий заказ
|
||||
customer: Клиент, для которого создается заказ
|
||||
data (dict, optional): Дополнительные данные для заказа
|
||||
|
||||
Returns:
|
||||
Order: Созданный черновик заказа
|
||||
|
||||
Raises:
|
||||
ValidationError: Если данные невалидны
|
||||
"""
|
||||
data = data or {}
|
||||
|
||||
with transaction.atomic():
|
||||
# Получаем или создаем статус 'draft'
|
||||
from ..models import OrderStatus
|
||||
draft_status, _ = OrderStatus.objects.get_or_create(
|
||||
code='draft',
|
||||
defaults={
|
||||
'name': 'Черновик',
|
||||
'label': 'Черновик',
|
||||
'is_system': True,
|
||||
'color': '#808080',
|
||||
}
|
||||
)
|
||||
|
||||
order = Order.objects.create(
|
||||
customer=customer,
|
||||
status=draft_status,
|
||||
modified_by=user,
|
||||
is_delivery=data.get('is_delivery', True),
|
||||
delivery_address=data.get('delivery_address'),
|
||||
pickup_warehouse=data.get('pickup_warehouse'),
|
||||
delivery_date=data.get('delivery_date'),
|
||||
delivery_time_start=data.get('delivery_time_start'),
|
||||
delivery_time_end=data.get('delivery_time_end'),
|
||||
delivery_cost=data.get('delivery_cost', Decimal('0')),
|
||||
customer_is_recipient=data.get('customer_is_recipient', True),
|
||||
recipient_name=data.get('recipient_name'),
|
||||
recipient_phone=data.get('recipient_phone'),
|
||||
is_anonymous=data.get('is_anonymous', False),
|
||||
special_instructions=data.get('special_instructions'),
|
||||
last_autosave_at=timezone.now(),
|
||||
)
|
||||
|
||||
return order
|
||||
|
||||
@staticmethod
|
||||
def update_draft(order_id, user, data):
|
||||
"""
|
||||
Обновляет существующий заказ (автосохранение).
|
||||
|
||||
Args:
|
||||
order_id (int): ID заказа
|
||||
user: Пользователь, изменяющий заказ
|
||||
data (dict): Данные для обновления
|
||||
|
||||
Returns:
|
||||
Order: Обновленный заказ
|
||||
|
||||
Raises:
|
||||
Order.DoesNotExist: Если заказ не найден
|
||||
ValidationError: Если данные невалидны
|
||||
"""
|
||||
with transaction.atomic():
|
||||
order = Order.objects.select_for_update().get(pk=order_id)
|
||||
|
||||
# Обновляем только переданные поля
|
||||
# ForeignKey поля требуют специальной обработки
|
||||
fk_fields = {
|
||||
'customer': 'customers.Customer',
|
||||
'pickup_warehouse': 'inventory.Warehouse',
|
||||
'status': 'orders.OrderStatus',
|
||||
}
|
||||
|
||||
simple_fields = [
|
||||
'is_delivery', 'delivery_date', 'delivery_time_start', 'delivery_time_end',
|
||||
'delivery_cost', 'customer_is_recipient',
|
||||
'recipient_name', 'recipient_phone', 'is_anonymous',
|
||||
'special_instructions', 'discount_amount'
|
||||
]
|
||||
|
||||
# Обрабатываем ForeignKey поля
|
||||
for field_name, model_path in fk_fields.items():
|
||||
if field_name in data and data[field_name]:
|
||||
# Получаем модель
|
||||
app_label, model_name = model_path.split('.')
|
||||
from django.apps import apps
|
||||
Model = apps.get_model(app_label, model_name)
|
||||
|
||||
# Получаем объект по ID
|
||||
try:
|
||||
instance = Model.objects.get(pk=data[field_name])
|
||||
setattr(order, field_name, instance)
|
||||
except Model.DoesNotExist:
|
||||
pass # Игнорируем несуществующие объекты
|
||||
|
||||
# === Обработка адреса доставки ===
|
||||
# Новая логика с выбором режима адреса
|
||||
if 'address_mode' in data:
|
||||
address = AddressService.process_address_from_form(order, data)
|
||||
if address:
|
||||
# Если адрес не существует в БД, сохраняем его
|
||||
if not address.pk:
|
||||
address.save()
|
||||
order.delivery_address = address
|
||||
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()
|
||||
elif 'delivery_address' in data and data['delivery_address']:
|
||||
# Старая логика для совместимости (если передается delivery_address напрямую)
|
||||
try:
|
||||
address = Address.objects.get(pk=data['delivery_address'])
|
||||
order.delivery_address = address
|
||||
except Address.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Обрабатываем простые поля
|
||||
for field in simple_fields:
|
||||
if field in data:
|
||||
value = data[field]
|
||||
|
||||
# Конвертируем boolean поля
|
||||
if field in ['is_delivery', 'customer_is_recipient', 'is_anonymous']:
|
||||
# Явно конвертируем в bool, обрабатывая различные типы данных
|
||||
original_value = value
|
||||
if isinstance(value, bool):
|
||||
value = value
|
||||
elif isinstance(value, str):
|
||||
value = value.lower() in ('true', '1', 'yes', 'on')
|
||||
elif value is None:
|
||||
value = False
|
||||
else:
|
||||
value = bool(value)
|
||||
|
||||
# Логируем для отладки
|
||||
if field == 'is_delivery':
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"[AUTOSAVE] is_delivery: original={original_value} (type={type(original_value)}), converted={value}")
|
||||
|
||||
# Конвертируем числовые поля в Decimal
|
||||
elif field in ['delivery_cost', 'discount_amount']:
|
||||
if value == '' or value is None:
|
||||
value = None
|
||||
else:
|
||||
try:
|
||||
value = Decimal(str(value))
|
||||
except (ValueError, TypeError, decimal.InvalidOperation):
|
||||
value = Decimal('0')
|
||||
|
||||
# Конвертируем дату
|
||||
elif field == 'delivery_date':
|
||||
if value == '' or value is None:
|
||||
value = None
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
value = datetime.strptime(value, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
value = None
|
||||
|
||||
# Конвертируем время
|
||||
elif field in ['delivery_time_start', 'delivery_time_end']:
|
||||
if value == '' or value is None:
|
||||
value = None
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
# Формат времени может быть HH:MM или HH:MM:SS
|
||||
if len(value.split(':')) == 2:
|
||||
value = datetime.strptime(value, '%H:%M').time()
|
||||
else:
|
||||
value = datetime.strptime(value, '%H:%M:%S').time()
|
||||
except ValueError:
|
||||
value = None
|
||||
|
||||
setattr(order, field, value)
|
||||
|
||||
# Обрабатываем удаление позиций заказа
|
||||
if 'deleted_item_ids' in data:
|
||||
deleted_ids = data['deleted_item_ids']
|
||||
if deleted_ids:
|
||||
from ..models import OrderItem
|
||||
OrderItem.objects.filter(id__in=deleted_ids, order=order).delete()
|
||||
|
||||
# Обрабатываем позиции заказа (items)
|
||||
if 'items' in data:
|
||||
# Импортируем модели
|
||||
from products.models import Product, ProductKit
|
||||
from ..models import OrderItem
|
||||
|
||||
items_data = data['items']
|
||||
|
||||
# Обрабатываем каждую позицию
|
||||
for item_data in items_data:
|
||||
item_id = item_data.get('id') # ID существующей позиции (если есть)
|
||||
product_id = item_data.get('product_id')
|
||||
product_kit_id = item_data.get('product_kit_id')
|
||||
quantity = item_data.get('quantity', 1)
|
||||
price_raw = item_data.get('price', '')
|
||||
|
||||
# Конвертируем количество в Decimal
|
||||
try:
|
||||
quantity = Decimal(str(quantity))
|
||||
except (ValueError, TypeError, decimal.InvalidOperation):
|
||||
continue
|
||||
|
||||
# Получаем товар или комплект
|
||||
product = None
|
||||
product_kit = None
|
||||
|
||||
if product_id:
|
||||
try:
|
||||
product = Product.objects.get(pk=product_id)
|
||||
except Product.DoesNotExist:
|
||||
continue
|
||||
elif product_kit_id:
|
||||
try:
|
||||
product_kit = ProductKit.objects.get(pk=product_kit_id)
|
||||
except ProductKit.DoesNotExist:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
# Определяем оригинальную цену из каталога
|
||||
original_price = product.actual_price if product else product_kit.actual_price
|
||||
|
||||
# Конвертируем цену в Decimal, если пустая - используем оригинальную
|
||||
try:
|
||||
price = Decimal(str(price_raw)) if price_raw else Decimal('0')
|
||||
# Если цена 0 или пустая, используем оригинальную цену
|
||||
if price == Decimal('0'):
|
||||
price = original_price
|
||||
is_custom_price = False
|
||||
else:
|
||||
# Определяем, изменилась ли цена
|
||||
is_custom_price = abs(price - original_price) > Decimal('0.01')
|
||||
except (ValueError, TypeError, decimal.InvalidOperation):
|
||||
# В случае ошибки используем оригинальную цену
|
||||
price = original_price
|
||||
is_custom_price = False
|
||||
|
||||
# Обновляем существующую позицию или создаём новую
|
||||
if item_id:
|
||||
# Обновляем существующую позицию
|
||||
try:
|
||||
item = OrderItem.objects.get(id=item_id, order=order)
|
||||
item.product = product
|
||||
item.product_kit = product_kit
|
||||
item.quantity = quantity
|
||||
item.price = price
|
||||
item.is_custom_price = is_custom_price
|
||||
item.save()
|
||||
except OrderItem.DoesNotExist:
|
||||
# Если позиция не найдена, создаём новую
|
||||
OrderItem.objects.create(
|
||||
order=order,
|
||||
product=product,
|
||||
product_kit=product_kit,
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
is_custom_price=is_custom_price
|
||||
)
|
||||
else:
|
||||
# Создаём новую позицию
|
||||
OrderItem.objects.create(
|
||||
order=order,
|
||||
product=product,
|
||||
product_kit=product_kit,
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
is_custom_price=is_custom_price
|
||||
)
|
||||
|
||||
# Обрабатываем удаление платежей
|
||||
if 'deleted_payment_ids' in data:
|
||||
deleted_payment_ids = data['deleted_payment_ids']
|
||||
if deleted_payment_ids:
|
||||
from ..models import Payment
|
||||
Payment.objects.filter(id__in=deleted_payment_ids, order=order).delete()
|
||||
|
||||
# Обрабатываем платежи (payments)
|
||||
if 'payments' in data:
|
||||
from ..models import Payment, PaymentMethod
|
||||
payments_data = data['payments']
|
||||
|
||||
# Обрабатываем каждый платеж
|
||||
for payment_data in payments_data:
|
||||
payment_id = payment_data.get('id') # ID существующего платежа (если есть)
|
||||
payment_method_id = payment_data.get('payment_method_id')
|
||||
amount_raw = payment_data.get('amount', '')
|
||||
notes = payment_data.get('notes', '')
|
||||
|
||||
# Пропускаем пустые платежи
|
||||
if not payment_method_id or not amount_raw:
|
||||
continue
|
||||
|
||||
# Конвертируем сумму в Decimal
|
||||
try:
|
||||
amount = Decimal(str(amount_raw))
|
||||
if amount <= 0:
|
||||
continue
|
||||
except (ValueError, TypeError, decimal.InvalidOperation):
|
||||
continue
|
||||
|
||||
# Получаем способ оплаты
|
||||
try:
|
||||
payment_method = PaymentMethod.objects.get(pk=payment_method_id)
|
||||
except PaymentMethod.DoesNotExist:
|
||||
continue
|
||||
|
||||
# Обновляем существующий платеж или создаём новый
|
||||
if payment_id:
|
||||
# Обновляем существующий платеж
|
||||
try:
|
||||
payment = Payment.objects.get(id=payment_id, order=order)
|
||||
payment.payment_method = payment_method
|
||||
payment.amount = amount
|
||||
payment.notes = notes
|
||||
payment.save()
|
||||
except Payment.DoesNotExist:
|
||||
# Если платеж не найден, создаём новый
|
||||
Payment.objects.create(
|
||||
order=order,
|
||||
payment_method=payment_method,
|
||||
amount=amount,
|
||||
notes=notes,
|
||||
created_by=user
|
||||
)
|
||||
else:
|
||||
# Создаём новый платеж
|
||||
Payment.objects.create(
|
||||
order=order,
|
||||
payment_method=payment_method,
|
||||
amount=amount,
|
||||
notes=notes,
|
||||
created_by=user
|
||||
)
|
||||
|
||||
order.modified_by = user
|
||||
order.last_autosave_at = timezone.now()
|
||||
order.save()
|
||||
|
||||
# Пересчитываем итоговую сумму если изменились товары
|
||||
if 'recalculate' in data and data['recalculate']:
|
||||
order.calculate_total()
|
||||
order.save()
|
||||
|
||||
return order
|
||||
|
||||
@staticmethod
|
||||
def add_item_to_draft(order_id, product_id=None, product_kit_id=None, quantity=1, price=None):
|
||||
"""
|
||||
Добавляет товар или комплект в черновик заказа.
|
||||
|
||||
Args:
|
||||
order_id (int): ID заказа
|
||||
product_id (int, optional): ID товара
|
||||
product_kit_id (int, optional): ID комплекта
|
||||
quantity (Decimal): Количество
|
||||
price (Decimal, optional): Цена (если None, берется из товара/комплекта)
|
||||
|
||||
Returns:
|
||||
OrderItem: Созданная позиция заказа
|
||||
|
||||
Raises:
|
||||
ValidationError: Если заказ не является черновиком или данные невалидны
|
||||
"""
|
||||
with transaction.atomic():
|
||||
order = Order.objects.get(pk=order_id)
|
||||
|
||||
# Определяем товар или комплект
|
||||
product = None
|
||||
product_kit = None
|
||||
|
||||
if product_id:
|
||||
product = Product.objects.get(pk=product_id)
|
||||
if price is None:
|
||||
price = product.actual_price
|
||||
elif product_kit_id:
|
||||
product_kit = ProductKit.objects.get(pk=product_kit_id)
|
||||
if price is None:
|
||||
price = product_kit.actual_price
|
||||
else:
|
||||
raise ValidationError("Необходимо указать product_id или product_kit_id")
|
||||
|
||||
order_item = OrderItem.objects.create(
|
||||
order=order,
|
||||
product=product,
|
||||
product_kit=product_kit,
|
||||
quantity=quantity,
|
||||
price=price
|
||||
)
|
||||
|
||||
# Обновляем итоговую сумму заказа
|
||||
order.calculate_total()
|
||||
order.last_autosave_at = timezone.now()
|
||||
order.save()
|
||||
|
||||
return order_item
|
||||
|
||||
@staticmethod
|
||||
def remove_item_from_draft(order_id, order_item_id):
|
||||
"""
|
||||
Удаляет позицию из черновика заказа.
|
||||
|
||||
Args:
|
||||
order_id (int): ID заказа
|
||||
order_item_id (int): ID позиции заказа
|
||||
|
||||
Raises:
|
||||
ValidationError: Если заказ не является черновиком
|
||||
"""
|
||||
with transaction.atomic():
|
||||
order = Order.objects.get(pk=order_id)
|
||||
|
||||
OrderItem.objects.filter(pk=order_item_id, order=order).delete()
|
||||
|
||||
# Обновляем итоговую сумму заказа
|
||||
order.calculate_total()
|
||||
order.last_autosave_at = timezone.now()
|
||||
order.save()
|
||||
|
||||
@staticmethod
|
||||
def finalize_draft(order_id, user):
|
||||
"""
|
||||
Завершает черновик заказа, переводя его в статус 'new'.
|
||||
Выполняет финальную валидацию всех данных.
|
||||
|
||||
Args:
|
||||
order_id (int): ID заказа
|
||||
user: Пользователь, завершающий заказ
|
||||
|
||||
Returns:
|
||||
Order: Финализированный заказ
|
||||
|
||||
Raises:
|
||||
ValidationError: Если данные заказа невалидны или заказ не является черновиком
|
||||
"""
|
||||
with transaction.atomic():
|
||||
order = Order.objects.select_for_update().get(pk=order_id)
|
||||
|
||||
if not order.is_draft():
|
||||
raise ValidationError("Можно финализировать только черновики заказов")
|
||||
|
||||
# Проверяем наличие товаров
|
||||
if not order.items.exists():
|
||||
raise ValidationError("Заказ должен содержать хотя бы один товар")
|
||||
|
||||
# Выполняем полную валидацию модели
|
||||
order.full_clean()
|
||||
|
||||
# Получаем или создаем статус 'new'
|
||||
from ..models import OrderStatus
|
||||
new_status, _ = OrderStatus.objects.get_or_create(
|
||||
code='new',
|
||||
defaults={
|
||||
'name': 'Новый',
|
||||
'label': 'Новый',
|
||||
'is_system': True,
|
||||
'color': '#0d6efd',
|
||||
}
|
||||
)
|
||||
|
||||
# Изменяем статус на 'new'
|
||||
order.status = new_status
|
||||
order.modified_by = user
|
||||
order.last_autosave_at = None # Очищаем, т.к. заказ больше не черновик
|
||||
order.save()
|
||||
|
||||
# Привязываем временные комплекты к заказу
|
||||
ProductKit.objects.filter(
|
||||
is_temporary=True,
|
||||
order=order
|
||||
).update(order=order)
|
||||
|
||||
return order
|
||||
|
||||
@staticmethod
|
||||
def get_user_drafts(user, customer=None):
|
||||
"""
|
||||
Возвращает черновики заказов пользователя.
|
||||
|
||||
Args:
|
||||
user: Пользователь
|
||||
customer (Customer, optional): Фильтр по клиенту
|
||||
|
||||
Returns:
|
||||
QuerySet: Черновики заказов
|
||||
"""
|
||||
drafts = Order.objects.filter(
|
||||
status__code='draft',
|
||||
modified_by=user
|
||||
).select_related('customer', 'delivery_address', 'pickup_warehouse')
|
||||
|
||||
if customer:
|
||||
drafts = drafts.filter(customer=customer)
|
||||
|
||||
return drafts.order_by('-last_autosave_at')
|
||||
|
||||
@staticmethod
|
||||
def delete_old_drafts(days=30):
|
||||
"""
|
||||
Удаляет старые черновики заказов.
|
||||
|
||||
Args:
|
||||
days (int): Количество дней, после которых черновик считается старым
|
||||
|
||||
Returns:
|
||||
int: Количество удаленных черновиков
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
# Находим старые черновики
|
||||
old_drafts = Order.objects.filter(
|
||||
status__code='draft',
|
||||
last_autosave_at__lt=cutoff_date
|
||||
)
|
||||
|
||||
# Удаляем связанные временные комплекты
|
||||
for draft in old_drafts:
|
||||
ProductKit.objects.filter(
|
||||
is_temporary=True,
|
||||
order=draft
|
||||
).delete()
|
||||
|
||||
# Удаляем черновики
|
||||
count = old_drafts.count()
|
||||
old_drafts.delete()
|
||||
|
||||
return count
|
||||
@@ -27,6 +27,21 @@ class OrderStatusService:
|
||||
except OrderStatus.DoesNotExist:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_new_status():
|
||||
"""Возвращает системный статус 'new' (новый заказ)"""
|
||||
status, created = OrderStatus.objects.get_or_create(
|
||||
code='new',
|
||||
defaults={
|
||||
'name': 'Новый',
|
||||
'label': 'Новый',
|
||||
'is_system': True,
|
||||
'color': '#0d6efd',
|
||||
'order': 10,
|
||||
}
|
||||
)
|
||||
return status
|
||||
|
||||
@staticmethod
|
||||
def get_system_status(code):
|
||||
"""Получить системный статус по коду"""
|
||||
|
||||
@@ -1,664 +0,0 @@
|
||||
/**
|
||||
* Модуль автосохранения черновиков заказов.
|
||||
*
|
||||
* Автоматически сохраняет изменения в черновике заказа при изменении полей формы.
|
||||
* Использует debouncing для уменьшения количества запросов к серверу.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Конфигурация
|
||||
const CONFIG = {
|
||||
AUTOSAVE_DELAY: 3000, // Задержка перед автосохранением (мс)
|
||||
AUTOSAVE_URL_PATTERN: '/orders/{orderNumber}/autosave/',
|
||||
STATUS_DISPLAY_DURATION: 5000, // Длительность показа статуса (мс)
|
||||
};
|
||||
|
||||
// Состояние модуля
|
||||
let autosaveTimer = null;
|
||||
let isAutosaving = false;
|
||||
let orderNumber = null;
|
||||
|
||||
/**
|
||||
* Инициализация модуля автосохранения
|
||||
*/
|
||||
function init() {
|
||||
// Проверяем, что мы на странице редактирования
|
||||
const isEditPage = window.location.pathname.includes('/edit/');
|
||||
if (!isEditPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderForm = document.getElementById('order-form');
|
||||
if (!orderForm) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Получаем номер заказа из URL
|
||||
const urlMatch = window.location.pathname.match(/\/orders\/(\d+)\/edit\//);
|
||||
if (!urlMatch) {
|
||||
return;
|
||||
}
|
||||
orderNumber = urlMatch[1];
|
||||
|
||||
// Инициализируем UI индикатора
|
||||
initStatusIndicator();
|
||||
|
||||
// Добавляем обработчики событий
|
||||
attachEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает индикатор статуса автосохранения
|
||||
*/
|
||||
function initStatusIndicator() {
|
||||
// Проверяем, не создан ли уже индикатор
|
||||
if (document.getElementById('autosave-status')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const indicator = document.createElement('div');
|
||||
indicator.id = 'autosave-status';
|
||||
indicator.className = 'alert alert-info';
|
||||
indicator.style.cssText = `
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
right: 20px;
|
||||
z-index: 1050;
|
||||
min-width: 250px;
|
||||
display: none;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
`;
|
||||
indicator.innerHTML = `
|
||||
<div class="d-flex align-items-center">
|
||||
<span id="autosave-icon" class="me-2"></span>
|
||||
<span id="autosave-text">Автосохранение...</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(indicator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Показывает статус автосохранения
|
||||
*/
|
||||
function showStatus(type, message) {
|
||||
const indicator = document.getElementById('autosave-status');
|
||||
const icon = document.getElementById('autosave-icon');
|
||||
const text = document.getElementById('autosave-text');
|
||||
|
||||
if (!indicator || !icon || !text) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Убираем все классы
|
||||
indicator.className = 'alert';
|
||||
|
||||
// Устанавливаем соответствующий класс и иконку
|
||||
switch (type) {
|
||||
case 'saving':
|
||||
indicator.classList.add('alert-info');
|
||||
icon.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span>';
|
||||
break;
|
||||
case 'success':
|
||||
indicator.classList.add('alert-success');
|
||||
icon.innerHTML = '<i class="bi bi-check-circle-fill"></i>';
|
||||
break;
|
||||
case 'error':
|
||||
indicator.classList.add('alert-danger');
|
||||
icon.innerHTML = '<i class="bi bi-exclamation-triangle-fill"></i>';
|
||||
break;
|
||||
}
|
||||
|
||||
text.textContent = message;
|
||||
indicator.style.display = 'block';
|
||||
|
||||
// Автоматически скрываем статус (кроме ошибок)
|
||||
if (type !== 'error') {
|
||||
setTimeout(() => {
|
||||
indicator.style.display = 'none';
|
||||
}, CONFIG.STATUS_DISPLAY_DURATION);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Прикрепляет обработчики событий к полям формы
|
||||
*/
|
||||
function attachEventListeners() {
|
||||
const form = document.getElementById('order-form');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Слушаем изменения в основных полях заказа
|
||||
const fieldsToWatch = [
|
||||
'select[name="customer"]',
|
||||
'select[name="status"]',
|
||||
'input[name="delivery_date"]',
|
||||
'input[name="delivery_time_start"]',
|
||||
'input[name="delivery_time_end"]',
|
||||
'input[name="delivery_cost"]',
|
||||
'textarea[name="special_instructions"]',
|
||||
'input[name="discount_amount"]',
|
||||
'input[type="checkbox"]',
|
||||
'input[type="radio"]',
|
||||
'select[name="delivery_address"]',
|
||||
'select[name="pickup_warehouse"]',
|
||||
// Поля адреса доставки
|
||||
'input[name="address_street"]',
|
||||
'input[name="address_building_number"]',
|
||||
'input[name="address_apartment_number"]',
|
||||
'input[name="address_entrance"]',
|
||||
'input[name="address_floor"]',
|
||||
'input[name="address_intercom_code"]',
|
||||
'textarea[name="address_delivery_instructions"]',
|
||||
// Поля получателя
|
||||
'input[name="recipient_name"]',
|
||||
'input[name="recipient_phone"]',
|
||||
];
|
||||
|
||||
fieldsToWatch.forEach(selector => {
|
||||
const fields = form.querySelectorAll(selector);
|
||||
fields.forEach(field => {
|
||||
// Для select и checkbox используем 'change'
|
||||
if (field.tagName === 'SELECT' || field.type === 'checkbox' || field.type === 'radio') {
|
||||
field.addEventListener('change', scheduleAutosave);
|
||||
} else {
|
||||
// Для текстовых полей используем 'input'
|
||||
field.addEventListener('input', scheduleAutosave);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Слушаем изменения в формах товаров (formset)
|
||||
observeFormsetChanges();
|
||||
|
||||
// Слушаем изменения в формах платежей (payment formset)
|
||||
observePaymentFormsetChanges();
|
||||
}
|
||||
|
||||
/**
|
||||
* Наблюдает за изменениями в формсете товаров
|
||||
*/
|
||||
function observeFormsetChanges() {
|
||||
const formsetContainer = document.getElementById('order-items-container');
|
||||
if (!formsetContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Наблюдаем за добавлением/удалением форм
|
||||
const observer = new MutationObserver(() => {
|
||||
attachFormsetEventListeners();
|
||||
});
|
||||
|
||||
observer.observe(formsetContainer, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Прикрепляем обработчики к существующим формам
|
||||
attachFormsetEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Прикрепляет обработчики к полям в формах товаров
|
||||
*/
|
||||
function attachFormsetEventListeners() {
|
||||
const itemForms = document.querySelectorAll('.order-item-form');
|
||||
|
||||
itemForms.forEach(form => {
|
||||
// Если уже прикреплены обработчики, пропускаем
|
||||
if (form.dataset.autosaveAttached === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = form.querySelectorAll('select, input[type="number"], input[type="text"], input[type="checkbox"]');
|
||||
|
||||
fields.forEach(field => {
|
||||
if (field.tagName === 'SELECT' || field.type === 'checkbox') {
|
||||
field.addEventListener('change', scheduleAutosave);
|
||||
|
||||
// Для Select2 добавляем дополнительный обработчик
|
||||
if (window.jQuery && jQuery(field).data('select2')) {
|
||||
jQuery(field).on('select2:select', scheduleAutosave);
|
||||
}
|
||||
} else {
|
||||
field.addEventListener('input', scheduleAutosave);
|
||||
}
|
||||
});
|
||||
|
||||
form.dataset.autosaveAttached = 'true';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Наблюдает за изменениями в формсете платежей
|
||||
*/
|
||||
function observePaymentFormsetChanges() {
|
||||
const paymentsContainer = document.getElementById('payments-container');
|
||||
if (!paymentsContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Наблюдаем за добавлением/удалением форм платежей
|
||||
const observer = new MutationObserver(() => {
|
||||
attachPaymentFormsetEventListeners();
|
||||
});
|
||||
|
||||
observer.observe(paymentsContainer, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Прикрепляем обработчики к существующим формам
|
||||
attachPaymentFormsetEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Прикрепляет обработчики к полям в формах платежей
|
||||
*/
|
||||
function attachPaymentFormsetEventListeners() {
|
||||
const paymentForms = document.querySelectorAll('.payment-form');
|
||||
|
||||
paymentForms.forEach(form => {
|
||||
// Если уже прикреплены обработчики, пропускаем
|
||||
if (form.dataset.autosavePaymentAttached === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = form.querySelectorAll('select, input[type="number"], input[type="text"], textarea, input[type="checkbox"]');
|
||||
|
||||
fields.forEach(field => {
|
||||
if (field.tagName === 'SELECT' || field.type === 'checkbox') {
|
||||
field.addEventListener('change', scheduleAutosave);
|
||||
} else {
|
||||
field.addEventListener('input', scheduleAutosave);
|
||||
}
|
||||
});
|
||||
|
||||
form.dataset.autosavePaymentAttached = 'true';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Планирует автосохранение с задержкой (debouncing)
|
||||
*/
|
||||
function scheduleAutosave() {
|
||||
// Отменяем предыдущий таймер
|
||||
if (autosaveTimer) {
|
||||
clearTimeout(autosaveTimer);
|
||||
}
|
||||
|
||||
// Устанавливаем новый таймер
|
||||
autosaveTimer = setTimeout(() => {
|
||||
performAutosave();
|
||||
}, CONFIG.AUTOSAVE_DELAY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет автосохранение
|
||||
*/
|
||||
async function performAutosave() {
|
||||
if (isAutosaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
isAutosaving = true;
|
||||
showStatus('saving', 'Сохранение...');
|
||||
|
||||
try {
|
||||
// Собираем данные формы
|
||||
const formData = collectFormData();
|
||||
|
||||
// Отправляем AJAX запрос
|
||||
const url = CONFIG.AUTOSAVE_URL_PATTERN.replace('{orderNumber}', orderNumber);
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
const lastSaved = formatDateTime(data.last_saved);
|
||||
showStatus('success', 'Сохранено ' + lastSaved);
|
||||
} else {
|
||||
showStatus('error', 'Ошибка: ' + (data.error || 'Неизвестная ошибка'));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
showStatus('error', 'Ошибка соединения с сервером');
|
||||
} finally {
|
||||
isAutosaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает данные формы для отправки
|
||||
*/
|
||||
function collectFormData() {
|
||||
const form = document.getElementById('order-form');
|
||||
const data = {};
|
||||
|
||||
// Основные поля заказа
|
||||
const customerField = form.querySelector('select[name="customer"]');
|
||||
if (customerField && customerField.value) {
|
||||
data.customer = parseInt(customerField.value);
|
||||
}
|
||||
|
||||
const statusField = form.querySelector('select[name="status"]');
|
||||
if (statusField && statusField.value) {
|
||||
data.status = parseInt(statusField.value);
|
||||
}
|
||||
|
||||
const deliveryDateField = form.querySelector('input[name="delivery_date"]');
|
||||
if (deliveryDateField && deliveryDateField.value) {
|
||||
data.delivery_date = deliveryDateField.value;
|
||||
}
|
||||
|
||||
const deliveryTimeStartField = form.querySelector('input[name="delivery_time_start"]');
|
||||
if (deliveryTimeStartField && deliveryTimeStartField.value) {
|
||||
data.delivery_time_start = deliveryTimeStartField.value;
|
||||
}
|
||||
|
||||
const deliveryTimeEndField = form.querySelector('input[name="delivery_time_end"]');
|
||||
if (deliveryTimeEndField && deliveryTimeEndField.value) {
|
||||
data.delivery_time_end = deliveryTimeEndField.value;
|
||||
}
|
||||
|
||||
const deliveryCostField = form.querySelector('input[name="delivery_cost"]');
|
||||
if (deliveryCostField && deliveryCostField.value) {
|
||||
data.delivery_cost = deliveryCostField.value;
|
||||
}
|
||||
|
||||
const specialInstructionsField = form.querySelector('textarea[name="special_instructions"]');
|
||||
if (specialInstructionsField) {
|
||||
data.special_instructions = specialInstructionsField.value;
|
||||
}
|
||||
|
||||
const discountAmountField = form.querySelector('input[name="discount_amount"]');
|
||||
if (discountAmountField && discountAmountField.value) {
|
||||
data.discount_amount = discountAmountField.value;
|
||||
}
|
||||
|
||||
// Checkbox поля
|
||||
const isDeliveryField = form.querySelector('input[name="is_delivery"]');
|
||||
if (isDeliveryField) {
|
||||
data.is_delivery = isDeliveryField.checked;
|
||||
}
|
||||
|
||||
const customerIsRecipientField = form.querySelector('input[name="customer_is_recipient"]');
|
||||
if (customerIsRecipientField) {
|
||||
data.customer_is_recipient = customerIsRecipientField.checked;
|
||||
}
|
||||
|
||||
const isAnonymousField = form.querySelector('input[name="is_anonymous"]');
|
||||
if (isAnonymousField) {
|
||||
data.is_anonymous = isAnonymousField.checked;
|
||||
}
|
||||
|
||||
// Адрес доставки или точка самовывоза
|
||||
const deliveryAddressField = form.querySelector('select[name="delivery_address"]');
|
||||
if (deliveryAddressField && deliveryAddressField.value) {
|
||||
data.delivery_address = parseInt(deliveryAddressField.value);
|
||||
}
|
||||
|
||||
const pickupWarehouseField = form.querySelector('select[name="pickup_warehouse"]');
|
||||
if (pickupWarehouseField && pickupWarehouseField.value) {
|
||||
data.pickup_warehouse = parseInt(pickupWarehouseField.value);
|
||||
}
|
||||
|
||||
// Поля адреса доставки (новая логика с прямым вводом)
|
||||
const addressStreetField = form.querySelector('input[name="address_street"]');
|
||||
const addressBuildingField = form.querySelector('input[name="address_building_number"]');
|
||||
const addressApartmentField = form.querySelector('input[name="address_apartment_number"]');
|
||||
const addressEntranceField = form.querySelector('input[name="address_entrance"]');
|
||||
const addressFloorField = form.querySelector('input[name="address_floor"]');
|
||||
const addressIntercomField = form.querySelector('input[name="address_intercom_code"]');
|
||||
const addressInstructionsField = form.querySelector('textarea[name="address_delivery_instructions"]');
|
||||
|
||||
// Собираем все поля адреса
|
||||
const addressFields = {
|
||||
address_street: addressStreetField?.value || '',
|
||||
address_building_number: addressBuildingField?.value || '',
|
||||
address_apartment_number: addressApartmentField?.value || '',
|
||||
address_entrance: addressEntranceField?.value || '',
|
||||
address_floor: addressFloorField?.value || '',
|
||||
address_intercom_code: addressIntercomField?.value || '',
|
||||
address_delivery_instructions: addressInstructionsField?.value || '',
|
||||
};
|
||||
|
||||
// Проверяем, заполнено ли хотя бы одно поле адреса
|
||||
const hasAnyAddressData = Object.values(addressFields).some(value => value.trim() !== '');
|
||||
|
||||
if (hasAnyAddressData) {
|
||||
// Указываем режим "новый адрес" если заполнено хотя бы одно поле
|
||||
data.address_mode = 'new';
|
||||
|
||||
// Добавляем все непустые поля в данные
|
||||
Object.entries(addressFields).forEach(([key, value]) => {
|
||||
if (value.trim() !== '') {
|
||||
data[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const addressConfirmField = form.querySelector('input[name="address_confirm_with_recipient"]');
|
||||
if (addressConfirmField) {
|
||||
data.address_confirm_with_recipient = addressConfirmField.checked;
|
||||
}
|
||||
|
||||
// Поля получателя
|
||||
const recipientNameField = form.querySelector('input[name="recipient_name"]');
|
||||
if (recipientNameField && recipientNameField.value) {
|
||||
data.recipient_name = recipientNameField.value;
|
||||
}
|
||||
|
||||
const recipientPhoneField = form.querySelector('input[name="recipient_phone"]');
|
||||
if (recipientPhoneField && recipientPhoneField.value) {
|
||||
data.recipient_phone = recipientPhoneField.value;
|
||||
}
|
||||
|
||||
// Собираем позиции заказа
|
||||
const orderItemsData = collectOrderItems();
|
||||
data.items = orderItemsData.items;
|
||||
data.deleted_item_ids = orderItemsData.deletedItemIds;
|
||||
|
||||
// Собираем платежи
|
||||
const paymentsData = collectPayments();
|
||||
data.payments = paymentsData.payments;
|
||||
data.deleted_payment_ids = paymentsData.deletedPaymentIds;
|
||||
|
||||
// Флаг для пересчета итоговой суммы
|
||||
data.recalculate = true;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает данные о позициях заказа
|
||||
*/
|
||||
function collectOrderItems() {
|
||||
const items = [];
|
||||
const deletedItemIds = [];
|
||||
const itemForms = document.querySelectorAll('.order-item-form');
|
||||
|
||||
itemForms.forEach(form => {
|
||||
// Проверяем, помечена ли форма на удаление
|
||||
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
|
||||
const idField = form.querySelector('input[name$="-id"]');
|
||||
|
||||
if (deleteCheckbox && deleteCheckbox.checked) {
|
||||
// Если форма помечена на удаление и имеет ID, добавляем в список удалённых
|
||||
if (idField && idField.value) {
|
||||
deletedItemIds.push(parseInt(idField.value));
|
||||
}
|
||||
return; // Не добавляем в items
|
||||
}
|
||||
|
||||
// Получаем выбранный товар/комплект
|
||||
const itemSelect = form.querySelector('.select2-order-item');
|
||||
if (!itemSelect || !itemSelect.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemValue = itemSelect.value;
|
||||
const quantityInput = form.querySelector('input[name$="-quantity"]');
|
||||
const priceInput = form.querySelector('input[name$="-price"]');
|
||||
|
||||
if (!quantityInput || !priceInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = {
|
||||
quantity: quantityInput.value || '1',
|
||||
price: (priceInput.value || '0').replace(',', '.')
|
||||
};
|
||||
|
||||
// Если есть ID (существующий товар), добавляем его
|
||||
if (idField && idField.value) {
|
||||
item.id = parseInt(idField.value);
|
||||
}
|
||||
|
||||
// Определяем тип: товар или комплект
|
||||
if (itemValue.startsWith('product_')) {
|
||||
item.product_id = parseInt(itemValue.replace('product_', ''));
|
||||
} else if (itemValue.startsWith('kit_')) {
|
||||
item.product_kit_id = parseInt(itemValue.replace('kit_', ''));
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
});
|
||||
|
||||
return { items, deletedItemIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает данные о платежах
|
||||
*/
|
||||
function collectPayments() {
|
||||
const payments = [];
|
||||
const deletedPaymentIds = [];
|
||||
const paymentForms = document.querySelectorAll('.payment-form');
|
||||
|
||||
paymentForms.forEach(form => {
|
||||
// Проверяем, помечена ли форма на удаление
|
||||
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
|
||||
const idField = form.querySelector('input[name$="-id"]');
|
||||
|
||||
if (deleteCheckbox && deleteCheckbox.checked) {
|
||||
// Если форма помечена на удаление и имеет ID, добавляем в список удалённых
|
||||
if (idField && idField.value) {
|
||||
deletedPaymentIds.push(parseInt(idField.value));
|
||||
}
|
||||
return; // Не добавляем в payments
|
||||
}
|
||||
|
||||
// Получаем способ оплаты и сумму
|
||||
const paymentMethodSelect = form.querySelector('select[name$="-payment_method"]');
|
||||
const amountInput = form.querySelector('input[name$="-amount"]');
|
||||
const notesInput = form.querySelector('textarea[name$="-notes"]');
|
||||
|
||||
if (!paymentMethodSelect || !paymentMethodSelect.value || !amountInput || !amountInput.value) {
|
||||
return; // Пропускаем пустые платежи
|
||||
}
|
||||
|
||||
const payment = {
|
||||
payment_method_id: parseInt(paymentMethodSelect.value),
|
||||
amount: (amountInput.value || '0').replace(',', '.'),
|
||||
notes: notesInput ? notesInput.value : ''
|
||||
};
|
||||
|
||||
// Если есть ID (существующий платеж), добавляем его
|
||||
if (idField && idField.value) {
|
||||
payment.id = parseInt(idField.value);
|
||||
}
|
||||
|
||||
payments.push(payment);
|
||||
});
|
||||
|
||||
return { payments, deletedPaymentIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает CSRF токен из cookies или meta тега
|
||||
*/
|
||||
function getCsrfToken() {
|
||||
// Пробуем получить из cookie
|
||||
const name = 'csrftoken';
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Если не нашли в cookie, пробуем в meta теге
|
||||
if (!cookieValue) {
|
||||
const metaTag = document.querySelector('meta[name="csrf-token"]');
|
||||
if (metaTag) {
|
||||
cookieValue = metaTag.getAttribute('content');
|
||||
}
|
||||
}
|
||||
|
||||
// Если не нашли в meta теге, пробуем в input поле
|
||||
if (!cookieValue) {
|
||||
const csrfInput = document.querySelector('input[name="csrfmiddlewaretoken"]');
|
||||
if (csrfInput) {
|
||||
cookieValue = csrfInput.value;
|
||||
}
|
||||
}
|
||||
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует дату и время для отображения
|
||||
*/
|
||||
function formatDateTime(isoString) {
|
||||
if (!isoString) {
|
||||
return 'только что';
|
||||
}
|
||||
|
||||
const date = new Date(isoString);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
|
||||
if (diffSecs < 60) {
|
||||
return 'только что';
|
||||
} else if (diffMins < 60) {
|
||||
return diffMins + ' мин. назад';
|
||||
} else {
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
return 'в ' + hours + ':' + minutes;
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализация при загрузке DOM
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
// Экспортируем функцию scheduleAutosave в глобальную область
|
||||
window.orderAutosave = {
|
||||
scheduleAutosave: scheduleAutosave
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -1,440 +0,0 @@
|
||||
/**
|
||||
* Модуль автоматического создания черновика заказа.
|
||||
*
|
||||
* При первом изменении формы создания заказа автоматически создаёт черновик
|
||||
* и перенаправляет пользователя на страницу редактирования черновика,
|
||||
* где уже работает обычное автосохранение.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Конфигурация
|
||||
const CONFIG = {
|
||||
CREATE_DRAFT_URL: '/orders/create-draft/',
|
||||
DEBOUNCE_DELAY: 2000, // Задержка перед созданием черновика (мс)
|
||||
};
|
||||
|
||||
// Состояние модуля
|
||||
let createDraftTimer = null;
|
||||
let isCreatingDraft = false;
|
||||
let draftCreated = false;
|
||||
|
||||
/**
|
||||
* Инициализация модуля
|
||||
*/
|
||||
function init() {
|
||||
// Проверяем, что мы на странице создания заказа
|
||||
const isCreatePage = window.location.pathname.includes('/orders/create/');
|
||||
if (!isCreatePage) {
|
||||
console.log('[DraftCreator] Not on create page, exiting');
|
||||
return;
|
||||
}
|
||||
|
||||
const orderForm = document.getElementById('order-form');
|
||||
if (!orderForm) {
|
||||
console.log('[DraftCreator] Order form not found, exiting');
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем, что это не черновик (для черновиков есть autosave.js)
|
||||
if (orderForm.dataset.isDraft === 'true') {
|
||||
console.log('[DraftCreator] This is a draft, exiting (autosave.js will handle it)');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[DraftCreator] Initialized on order create page');
|
||||
|
||||
// Добавляем обработчики событий
|
||||
attachEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Прикрепляет обработчики событий к полям формы
|
||||
*/
|
||||
function attachEventListeners() {
|
||||
const form = document.getElementById('order-form');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Слушаем изменения в поле клиента (обязательное поле)
|
||||
const customerField = form.querySelector('select[name="customer"]');
|
||||
if (customerField) {
|
||||
// Обычное событие change
|
||||
customerField.addEventListener('change', function() {
|
||||
console.log('[DraftCreator] Customer changed (native event):', this.value);
|
||||
if (this.value && !draftCreated) {
|
||||
scheduleCreateDraft();
|
||||
}
|
||||
});
|
||||
|
||||
// Событие Select2
|
||||
if (window.jQuery && jQuery(customerField).data('select2')) {
|
||||
jQuery(customerField).on('select2:select', function(e) {
|
||||
console.log('[DraftCreator] Customer changed (select2 event):', e.params.data.id);
|
||||
if (e.params.data.id && !draftCreated) {
|
||||
scheduleCreateDraft();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Черновик создаётся ТОЛЬКО при выборе клиента.
|
||||
// После создания и переадресации на страницу редактирования
|
||||
// уже работает полноценное автосохранение для всех полей и товаров.
|
||||
}
|
||||
|
||||
/**
|
||||
* Планирует создание черновика с задержкой (debouncing)
|
||||
*/
|
||||
function scheduleCreateDraft() {
|
||||
// Отменяем предыдущий таймер
|
||||
if (createDraftTimer) {
|
||||
clearTimeout(createDraftTimer);
|
||||
}
|
||||
|
||||
// Устанавливаем новый таймер
|
||||
createDraftTimer = setTimeout(() => {
|
||||
createDraft();
|
||||
}, CONFIG.DEBOUNCE_DELAY);
|
||||
|
||||
console.log('[DraftCreator] Scheduled draft creation in ' + CONFIG.DEBOUNCE_DELAY + 'ms');
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт черновик заказа
|
||||
*/
|
||||
async function createDraft() {
|
||||
if (isCreatingDraft || draftCreated) {
|
||||
console.log('[DraftCreator] Already creating or created, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
isCreatingDraft = true;
|
||||
console.log('[DraftCreator] Creating draft...');
|
||||
|
||||
try {
|
||||
// Собираем данные формы
|
||||
const formData = collectFormData();
|
||||
|
||||
// Проверяем наличие клиента
|
||||
if (!formData.customer) {
|
||||
console.log('[DraftCreator] No customer selected, skipping');
|
||||
isCreatingDraft = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Отправляем AJAX запрос
|
||||
const response = await fetch(CONFIG.CREATE_DRAFT_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCsrfToken(),
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
console.log('[DraftCreator] Draft created successfully:', data);
|
||||
draftCreated = true;
|
||||
|
||||
// Показываем уведомление
|
||||
showNotification('Черновик создан. Перенаправление...');
|
||||
|
||||
// Перенаправляем на страницу редактирования черновика
|
||||
setTimeout(() => {
|
||||
window.location.href = data.redirect_url;
|
||||
}, 500);
|
||||
|
||||
} else {
|
||||
console.error('[DraftCreator] Error creating draft:', data);
|
||||
|
||||
// Если ошибка не критичная (например, клиент не выбран), не показываем
|
||||
if (response.status !== 400) {
|
||||
showNotification('Ошибка создания черновика: ' + (data.error || 'Неизвестная ошибка'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DraftCreator] Exception:', error);
|
||||
showNotification('Ошибка соединения с сервером', 'error');
|
||||
} finally {
|
||||
isCreatingDraft = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает данные формы для отправки
|
||||
*/
|
||||
function collectFormData() {
|
||||
const form = document.getElementById('order-form');
|
||||
const data = {};
|
||||
|
||||
// Основные поля заказа
|
||||
const customerField = form.querySelector('select[name="customer"]');
|
||||
if (customerField && customerField.value) {
|
||||
data.customer = parseInt(customerField.value);
|
||||
}
|
||||
|
||||
const deliveryDateField = form.querySelector('input[name="delivery_date"]');
|
||||
if (deliveryDateField && deliveryDateField.value) {
|
||||
data.delivery_date = deliveryDateField.value;
|
||||
}
|
||||
|
||||
const deliveryTimeStartField = form.querySelector('input[name="delivery_time_start"]');
|
||||
if (deliveryTimeStartField && deliveryTimeStartField.value) {
|
||||
data.delivery_time_start = deliveryTimeStartField.value;
|
||||
}
|
||||
|
||||
const deliveryTimeEndField = form.querySelector('input[name="delivery_time_end"]');
|
||||
if (deliveryTimeEndField && deliveryTimeEndField.value) {
|
||||
data.delivery_time_end = deliveryTimeEndField.value;
|
||||
}
|
||||
|
||||
const deliveryCostField = form.querySelector('input[name="delivery_cost"]');
|
||||
if (deliveryCostField && deliveryCostField.value) {
|
||||
data.delivery_cost = deliveryCostField.value;
|
||||
}
|
||||
|
||||
const paymentMethodField = form.querySelector('select[name="payment_method"]');
|
||||
if (paymentMethodField && paymentMethodField.value) {
|
||||
data.payment_method = paymentMethodField.value;
|
||||
}
|
||||
|
||||
const specialInstructionsField = form.querySelector('textarea[name="special_instructions"]');
|
||||
if (specialInstructionsField) {
|
||||
data.special_instructions = specialInstructionsField.value;
|
||||
}
|
||||
|
||||
const discountAmountField = form.querySelector('input[name="discount_amount"]');
|
||||
if (discountAmountField && discountAmountField.value) {
|
||||
data.discount_amount = discountAmountField.value;
|
||||
}
|
||||
|
||||
// Checkbox поля
|
||||
const isDeliveryField = form.querySelector('input[name="is_delivery"]');
|
||||
if (isDeliveryField) {
|
||||
data.is_delivery = isDeliveryField.checked;
|
||||
}
|
||||
|
||||
const customerIsRecipientField = form.querySelector('input[name="customer_is_recipient"]');
|
||||
if (customerIsRecipientField) {
|
||||
data.customer_is_recipient = customerIsRecipientField.checked;
|
||||
}
|
||||
|
||||
const isAnonymousField = form.querySelector('input[name="is_anonymous"]');
|
||||
if (isAnonymousField) {
|
||||
data.is_anonymous = isAnonymousField.checked;
|
||||
}
|
||||
|
||||
// Адрес доставки или точка самовывоза
|
||||
const deliveryAddressField = form.querySelector('select[name="delivery_address"]');
|
||||
if (deliveryAddressField && deliveryAddressField.value) {
|
||||
data.delivery_address = parseInt(deliveryAddressField.value);
|
||||
}
|
||||
|
||||
const pickupWarehouseField = form.querySelector('select[name="pickup_warehouse"]');
|
||||
if (pickupWarehouseField && pickupWarehouseField.value) {
|
||||
data.pickup_warehouse = parseInt(pickupWarehouseField.value);
|
||||
}
|
||||
|
||||
// Новая логика выбора адреса
|
||||
const addressModeField = form.querySelector('input[name="address_mode"]:checked');
|
||||
if (addressModeField) {
|
||||
data.address_mode = addressModeField.value;
|
||||
|
||||
if (addressModeField.value === 'history') {
|
||||
const addressFromHistoryField = form.querySelector('select[name="address_from_history"]');
|
||||
if (addressFromHistoryField && addressFromHistoryField.value) {
|
||||
data.address_from_history = parseInt(addressFromHistoryField.value);
|
||||
}
|
||||
} else if (addressModeField.value === 'new') {
|
||||
// Собираем поля нового адреса
|
||||
const addressStreetField = form.querySelector('input[name="address_street"]');
|
||||
if (addressStreetField && addressStreetField.value) {
|
||||
data.address_street = addressStreetField.value;
|
||||
}
|
||||
|
||||
const addressBuildingField = form.querySelector('input[name="address_building_number"]');
|
||||
if (addressBuildingField && addressBuildingField.value) {
|
||||
data.address_building_number = addressBuildingField.value;
|
||||
}
|
||||
|
||||
const addressApartmentField = form.querySelector('input[name="address_apartment_number"]');
|
||||
if (addressApartmentField && addressApartmentField.value) {
|
||||
data.address_apartment_number = addressApartmentField.value;
|
||||
}
|
||||
|
||||
const addressEntranceField = form.querySelector('input[name="address_entrance"]');
|
||||
if (addressEntranceField && addressEntranceField.value) {
|
||||
data.address_entrance = addressEntranceField.value;
|
||||
}
|
||||
|
||||
const addressFloorField = form.querySelector('input[name="address_floor"]');
|
||||
if (addressFloorField && addressFloorField.value) {
|
||||
data.address_floor = addressFloorField.value;
|
||||
}
|
||||
|
||||
const addressIntercomField = form.querySelector('input[name="address_intercom_code"]');
|
||||
if (addressIntercomField && addressIntercomField.value) {
|
||||
data.address_intercom_code = addressIntercomField.value;
|
||||
}
|
||||
|
||||
const addressInstructionsField = form.querySelector('textarea[name="address_delivery_instructions"]');
|
||||
if (addressInstructionsField && addressInstructionsField.value) {
|
||||
data.address_delivery_instructions = addressInstructionsField.value;
|
||||
}
|
||||
|
||||
const addressConfirmField = form.querySelector('input[name="address_confirm_with_recipient"]');
|
||||
if (addressConfirmField) {
|
||||
data.address_confirm_with_recipient = addressConfirmField.checked;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Поля получателя
|
||||
const recipientNameField = form.querySelector('input[name="recipient_name"]');
|
||||
if (recipientNameField && recipientNameField.value) {
|
||||
data.recipient_name = recipientNameField.value;
|
||||
}
|
||||
|
||||
const recipientPhoneField = form.querySelector('input[name="recipient_phone"]');
|
||||
if (recipientPhoneField && recipientPhoneField.value) {
|
||||
data.recipient_phone = recipientPhoneField.value;
|
||||
}
|
||||
|
||||
// Собираем позиции заказа
|
||||
const items = collectOrderItems();
|
||||
if (items.length > 0) {
|
||||
data.items = items;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает данные о позициях заказа
|
||||
*/
|
||||
function collectOrderItems() {
|
||||
const items = [];
|
||||
const itemForms = document.querySelectorAll('.order-item-form');
|
||||
|
||||
itemForms.forEach((form, index) => {
|
||||
// Пропускаем удаленные формы
|
||||
const deleteCheckbox = form.querySelector('input[name$="-DELETE"]');
|
||||
if (deleteCheckbox && deleteCheckbox.checked) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Получаем выбранный товар/комплект
|
||||
const itemSelect = form.querySelector('.select2-order-item');
|
||||
if (!itemSelect || !itemSelect.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemValue = itemSelect.value;
|
||||
const quantityInput = form.querySelector('input[name$="-quantity"]');
|
||||
const priceInput = form.querySelector('input[name$="-price"]');
|
||||
|
||||
if (!quantityInput || !priceInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item = {
|
||||
quantity: quantityInput.value || '1',
|
||||
price: priceInput.value || '0'
|
||||
};
|
||||
|
||||
// Определяем тип: товар или комплект
|
||||
if (itemValue.startsWith('product_')) {
|
||||
item.product_id = parseInt(itemValue.replace('product_', ''));
|
||||
} else if (itemValue.startsWith('kit_')) {
|
||||
item.product_kit_id = parseInt(itemValue.replace('kit_', ''));
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает CSRF токен
|
||||
*/
|
||||
function getCsrfToken() {
|
||||
// Пробуем получить из cookie
|
||||
const name = 'csrftoken';
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Если не нашли в cookie, пробуем в input поле
|
||||
if (!cookieValue) {
|
||||
const csrfInput = document.querySelector('input[name="csrfmiddlewaretoken"]');
|
||||
if (csrfInput) {
|
||||
cookieValue = csrfInput.value;
|
||||
}
|
||||
}
|
||||
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Показывает уведомление пользователю
|
||||
*/
|
||||
function showNotification(message, type = 'info') {
|
||||
// Создаём простое уведомление
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `alert alert-${type === 'error' ? 'danger' : 'info'}`;
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
right: 20px;
|
||||
z-index: 1050;
|
||||
min-width: 250px;
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
`;
|
||||
notification.innerHTML = `
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="me-2">${type === 'error' ? '⚠️' : 'ℹ️'}</span>
|
||||
<span>${message}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Автоматически удаляем через 3 секунды
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Инициализация при загрузке DOM
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
// Экспортируем публичный API для вызова из модального окна
|
||||
window.DraftCreator = {
|
||||
triggerDraftCreation: function() {
|
||||
console.log('[DraftCreator] Triggered via API');
|
||||
scheduleCreateDraft();
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
@@ -831,14 +831,36 @@
|
||||
<!-- Кнопки -->
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-check-circle"></i> {{ button_text }}
|
||||
<div class="d-flex gap-2">
|
||||
{% if is_create_page %}
|
||||
<!-- На странице создания показываем обе кнопки -->
|
||||
<button type="submit" name="create_order" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-check-circle"></i> Создать заказ
|
||||
</button>
|
||||
<a href="{% url 'orders:order-list' %}" class="btn btn-secondary btn-lg">
|
||||
<button type="submit" name="save_as_draft" class="btn btn-secondary btn-lg">
|
||||
<i class="bi bi-save"></i> Сохранить как черновик
|
||||
</button>
|
||||
{% elif is_draft %}
|
||||
<!-- Для черновиков показываем кнопку финализации и обычного сохранения -->
|
||||
<button type="submit" name="finalize_draft" class="btn btn-success btn-lg">
|
||||
<i class="bi bi-check-circle-fill"></i> Финализировать черновик
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-save"></i> Сохранить изменения
|
||||
</button>
|
||||
{% else %}
|
||||
<!-- Для обычных заказов - только сохранение -->
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-save"></i> {{ button_text }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<a href="{% url 'orders:order-list' %}" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="bi bi-x-circle"></i> Отмена
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1000,7 +1022,7 @@ function initCustomerSelect2() {
|
||||
console.log('Значение восстановлено:', $customerSelect.val());
|
||||
}
|
||||
|
||||
// Уведомляем draft-creator.js что Select2 готов и есть предзаполненное значение
|
||||
// Select2 готов и есть предзаполненное значение
|
||||
if (currentValue && window.DraftCreator) {
|
||||
console.log('7. Уведомляем DraftCreator о предзаполненном клиенте');
|
||||
setTimeout(function() {
|
||||
@@ -1065,12 +1087,12 @@ function initCustomerSelect2() {
|
||||
if (data.is_create_option || data.id === '__create_new__') {
|
||||
console.log('11. Открываем модальное окно для создания клиента');
|
||||
this.value = '';
|
||||
// Триггерим нативное change событие для draft-creator.js
|
||||
// Триггерим нативное change событие
|
||||
const changeEvent = new Event('change', { bubbles: true });
|
||||
this.dispatchEvent(changeEvent);
|
||||
window.openCreateCustomerModal(data.search_text);
|
||||
} else {
|
||||
// Триггерим нативное change событие для других обработчиков (например, draft-creator.js)
|
||||
// Триггерим нативное change событие для других обработчиков
|
||||
console.log('12. Триггерим нативное change событие для customer ID:', data.id);
|
||||
const changeEvent = new Event('change', { bubbles: true });
|
||||
this.dispatchEvent(changeEvent);
|
||||
@@ -2181,12 +2203,37 @@ if (!document.getElementById('notification-styles')) {
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Скрипты автосохранения и создания черновиков -->
|
||||
{% if order %}
|
||||
<!-- Автосохранение при редактировании заказа -->
|
||||
<script src="{% static 'orders/js/autosave.js' %}"></script>
|
||||
{% else %}
|
||||
<!-- Автосоздание черновика при создании нового заказа -->
|
||||
<script src="{% static 'orders/js/draft-creator.js' %}"></script>
|
||||
{% endif %}
|
||||
<!-- Предупреждение о несохраненных данных -->
|
||||
<script>
|
||||
/**
|
||||
* Предупреждение при попытке покинуть страницу с несохраненными данными
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
let isSubmitting = false;
|
||||
|
||||
// При отправке формы - отключаем предупреждение
|
||||
const form = document.getElementById('order-form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function() {
|
||||
isSubmitting = true;
|
||||
});
|
||||
}
|
||||
|
||||
// Показываем предупреждение при закрытии/переходе
|
||||
window.addEventListener('beforeunload', function(e) {
|
||||
if (isSubmitting) {
|
||||
return; // Не показываем предупреждение если форма отправляется
|
||||
}
|
||||
|
||||
{% if is_create_page or is_draft %}
|
||||
// Только для создания заказа и редактирования черновика
|
||||
e.preventDefault();
|
||||
e.returnValue = 'Несохраненные данные будут потеряны. Вы уверены?';
|
||||
return e.returnValue;
|
||||
{% endif %}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -12,8 +12,6 @@ urlpatterns = [
|
||||
path('<int:order_number>/delete/', views.order_delete, name='order-delete'),
|
||||
|
||||
# AJAX endpoints
|
||||
path('<int:order_number>/autosave/', views.autosave_draft_order, name='order-autosave'),
|
||||
path('create-draft/', views.create_draft_from_form, name='order-create-draft'),
|
||||
path('api/customer-address-history/', views.get_customer_address_history, name='api-customer-address-history'),
|
||||
|
||||
# Wallet payment
|
||||
|
||||
@@ -11,7 +11,6 @@ from decimal import Decimal
|
||||
from .models import Order, OrderItem, Address, OrderStatus
|
||||
from .forms import OrderForm, OrderItemFormSet, OrderStatusForm, PaymentFormSet
|
||||
from .filters import OrderFilter
|
||||
from .services import DraftOrderService
|
||||
from .services.address_service import AddressService
|
||||
import json
|
||||
|
||||
@@ -80,11 +79,15 @@ def order_create(request):
|
||||
address.save()
|
||||
order.delivery_address = address
|
||||
|
||||
# Если нажата кнопка "Сохранить как черновик", создаем черновик
|
||||
# Проверяем какая кнопка нажата
|
||||
if 'save_as_draft' in request.POST:
|
||||
# Кнопка "Сохранить как черновик"
|
||||
from .services.order_status_service import OrderStatusService
|
||||
order.status = OrderStatusService.get_draft_status()
|
||||
order.modified_by = request.user
|
||||
else:
|
||||
# Кнопка "Создать заказ" - статус из формы или NULL
|
||||
order.modified_by = request.user
|
||||
|
||||
order.save()
|
||||
|
||||
@@ -131,6 +134,7 @@ def order_create(request):
|
||||
'preselected_customer': preselected_customer,
|
||||
'title': 'Создание заказа',
|
||||
'button_text': 'Создать заказ',
|
||||
'is_create_page': True,
|
||||
}
|
||||
|
||||
return render(request, 'orders/order_form.html', context)
|
||||
@@ -150,15 +154,29 @@ def order_update(request, order_number):
|
||||
|
||||
# Если черновик финализируется
|
||||
if 'finalize_draft' in request.POST and order.is_draft():
|
||||
try:
|
||||
order = DraftOrderService.finalize_draft(order.pk, request.user)
|
||||
from .services.order_status_service import OrderStatusService
|
||||
# Переводим в статус "Новый"
|
||||
order.status = OrderStatusService.get_new_status()
|
||||
order.modified_by = request.user
|
||||
|
||||
# Обрабатываем адрес доставки
|
||||
if order.is_delivery:
|
||||
address = AddressService.process_address_from_form(order, form.cleaned_data)
|
||||
if address:
|
||||
if not address.pk:
|
||||
address.save()
|
||||
order.delivery_address = address
|
||||
|
||||
order.save()
|
||||
formset.save()
|
||||
payment_formset.save()
|
||||
|
||||
# Пересчитываем итоговую сумму
|
||||
order.calculate_total()
|
||||
order.save()
|
||||
|
||||
messages.success(request, f'Черновик #{order.order_number} успешно завершен и переведен в статус "Новый"!')
|
||||
return redirect('orders:order-detail', order_number=order.order_number)
|
||||
except ValidationError as e:
|
||||
messages.error(request, f'Ошибка финализации: {str(e)}')
|
||||
form = OrderForm(instance=order)
|
||||
formset = OrderItemFormSet(instance=order)
|
||||
payment_formset = PaymentFormSet(instance=order)
|
||||
else:
|
||||
# Обрабатываем адрес доставки
|
||||
if order.is_delivery:
|
||||
@@ -255,250 +273,6 @@ def order_delete(request, order_number):
|
||||
# === AJAX ENDPOINTS ===
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
@login_required
|
||||
def autosave_draft_order(request, order_number):
|
||||
"""
|
||||
AJAX endpoint для автосохранения черновика заказа.
|
||||
|
||||
Принимает JSON с данными формы и обновляет черновик.
|
||||
Возвращает статус сохранения и время последнего сохранения.
|
||||
|
||||
Пример запроса:
|
||||
{
|
||||
"customer": 1,
|
||||
"is_delivery": true,
|
||||
"delivery_address": 5,
|
||||
"delivery_date": "2024-01-15",
|
||||
"special_instructions": "Позвонить за час",
|
||||
"items": [
|
||||
{"product_id": 10, "quantity": "2", "price": "500"},
|
||||
{"product_kit_id": 5, "quantity": "1", "price": "1500"}
|
||||
]
|
||||
}
|
||||
|
||||
Ответ при успехе:
|
||||
{
|
||||
"success": true,
|
||||
"last_saved": "2024-01-10T15:30:45.123456",
|
||||
"order_id": 123,
|
||||
"order_number": "ORD-000123"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
|
||||
# Проверяем существование заказа
|
||||
try:
|
||||
order = Order.objects.get(order_number=order_number)
|
||||
except Order.DoesNotExist:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Заказ не найден'
|
||||
}, status=404)
|
||||
|
||||
# Обновляем основные поля заказа из DraftOrderService (БЕЗ товаров)
|
||||
# Товары обрабатываем отдельно ниже
|
||||
order_fields_only = {k: v for k, v in data.items() if k not in ['items', 'payments']}
|
||||
order = DraftOrderService.update_draft(
|
||||
order_id=order.pk,
|
||||
user=request.user,
|
||||
data=order_fields_only
|
||||
)
|
||||
|
||||
# Обрабатываем позиции заказа, если они переданы
|
||||
if 'items' in data:
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
# Получаем ID товаров, которые нужно удалить
|
||||
deleted_item_ids = data.get('deleted_item_ids', [])
|
||||
if deleted_item_ids:
|
||||
order.items.filter(pk__in=deleted_item_ids).delete()
|
||||
|
||||
# Обрабатываем каждый товар
|
||||
for item_data in data['items']:
|
||||
item_id = item_data.get('id') # ID существующего товара (если есть)
|
||||
product_id = item_data.get('product_id')
|
||||
product_kit_id = item_data.get('product_kit_id')
|
||||
quantity = item_data.get('quantity')
|
||||
price_raw = item_data.get('price')
|
||||
|
||||
# Преобразуем цену
|
||||
try:
|
||||
price = Decimal(str(price_raw).replace(',', '.')) if price_raw else None
|
||||
except (ValueError, InvalidOperation):
|
||||
price = None
|
||||
|
||||
# Если есть ID - обновляем существующий товар
|
||||
if item_id:
|
||||
try:
|
||||
item = order.items.get(pk=item_id)
|
||||
# Обновляем поля
|
||||
if product_id:
|
||||
from products.models import Product
|
||||
item.product = Product.objects.get(pk=product_id)
|
||||
item.product_kit = None
|
||||
elif product_kit_id:
|
||||
from products.models import ProductKit
|
||||
item.product_kit = ProductKit.objects.get(pk=product_kit_id)
|
||||
item.product = None
|
||||
|
||||
if quantity:
|
||||
item.quantity = quantity
|
||||
if price is not None:
|
||||
item.price = price
|
||||
|
||||
item.save()
|
||||
except OrderItem.DoesNotExist:
|
||||
# Если товар не найден, создаем новый
|
||||
item_id = None
|
||||
|
||||
# Если нет ID - создаем новый товар
|
||||
if not item_id:
|
||||
if product_id:
|
||||
DraftOrderService.add_item_to_draft(
|
||||
order_id=order.pk,
|
||||
product_id=product_id,
|
||||
quantity=quantity,
|
||||
price=price
|
||||
)
|
||||
elif product_kit_id:
|
||||
DraftOrderService.add_item_to_draft(
|
||||
order_id=order.pk,
|
||||
product_kit_id=product_kit_id,
|
||||
quantity=quantity,
|
||||
price=price
|
||||
)
|
||||
|
||||
# НЕ ОБРАБАТЫВАЕМ ПЛАТЕЖИ В АВТОСОХРАНЕНИИ
|
||||
# Платежи обрабатываются только при ручном сохранении формы
|
||||
|
||||
# Пересчитываем итоговую сумму заказа и обновляем статус оплаты
|
||||
order.calculate_total()
|
||||
order.update_payment_status()
|
||||
order.save()
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'last_saved': order.last_autosave_at.isoformat() if order.last_autosave_at else None,
|
||||
'order_id': order.pk,
|
||||
'order_number': order.order_number
|
||||
})
|
||||
|
||||
except ValidationError as e:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=400)
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Некорректный JSON'
|
||||
}, status=400)
|
||||
except Exception as e:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': f'Ошибка сервера: {str(e)}'
|
||||
}, status=500)
|
||||
|
||||
|
||||
@require_http_methods(["POST"])
|
||||
@login_required
|
||||
def create_draft_from_form(request):
|
||||
"""
|
||||
AJAX endpoint для создания черновика заказа из формы создания.
|
||||
|
||||
Используется для автоматического создания черновика при первом изменении формы.
|
||||
После создания возвращает ID черновика для перенаправления.
|
||||
|
||||
Пример запроса:
|
||||
{
|
||||
"customer": 1,
|
||||
"is_delivery": true,
|
||||
"delivery_date": "2024-01-15"
|
||||
}
|
||||
|
||||
Ответ при успехе:
|
||||
{
|
||||
"success": true,
|
||||
"order_id": 123,
|
||||
"order_number": "ORD-000123",
|
||||
"redirect_url": "/orders/123/edit/"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
|
||||
# Получаем обязательное поле - клиента
|
||||
customer_id = data.get('customer')
|
||||
if not customer_id:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Необходимо выбрать клиента'
|
||||
}, status=400)
|
||||
|
||||
from customers.models import Customer
|
||||
try:
|
||||
customer = Customer.objects.get(pk=customer_id)
|
||||
except Customer.DoesNotExist:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Клиент не найден'
|
||||
}, status=404)
|
||||
|
||||
# Создаем черновик через DraftOrderService
|
||||
order = DraftOrderService.create_draft(
|
||||
user=request.user,
|
||||
customer=customer,
|
||||
data=data
|
||||
)
|
||||
|
||||
# Обрабатываем позиции заказа, если они переданы
|
||||
if 'items' in data:
|
||||
for item_data in data['items']:
|
||||
product_id = item_data.get('product_id')
|
||||
product_kit_id = item_data.get('product_kit_id')
|
||||
quantity = item_data.get('quantity')
|
||||
price = item_data.get('price')
|
||||
|
||||
if product_id:
|
||||
DraftOrderService.add_item_to_draft(
|
||||
order_id=order.pk,
|
||||
product_id=product_id,
|
||||
quantity=quantity,
|
||||
price=price
|
||||
)
|
||||
elif product_kit_id:
|
||||
DraftOrderService.add_item_to_draft(
|
||||
order_id=order.pk,
|
||||
product_kit_id=product_kit_id,
|
||||
quantity=quantity,
|
||||
price=price
|
||||
)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'order_id': order.pk,
|
||||
'order_number': order.order_number,
|
||||
'redirect_url': f'/orders/{order.order_number}/edit/'
|
||||
})
|
||||
|
||||
except ValidationError as e:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=400)
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Некорректный JSON'
|
||||
}, status=400)
|
||||
except Exception as e:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': f'Ошибка сервера: {str(e)}'
|
||||
}, status=500)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@login_required
|
||||
def get_customer_address_history(request):
|
||||
|
||||
Reference in New Issue
Block a user