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:
2025-11-28 23:29:19 +03:00
parent f911a57640
commit 9a44c98e6e
8 changed files with 110 additions and 1946 deletions

View File

@@ -1,7 +1,5 @@
"""
!5@28A=K9 A;>9 4;O ?@8;>65=8O orders.
Сервисный слой для приложения orders.
"""
from .draft_service import DraftOrderService
__all__ = ['DraftOrderService']
__all__ = []

View File

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

View File

@@ -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):
"""Получить системный статус по коду"""

View File

@@ -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
};
})();

View File

@@ -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();
}
};
})();

View File

@@ -831,12 +831,34 @@
<!-- Кнопки -->
<div class="row">
<div class="col">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-check-circle"></i> {{ button_text }}
</button>
<a href="{% url 'orders:order-list' %}" class="btn btn-secondary btn-lg">
<i class="bi bi-x-circle"></i> Отмена
</a>
<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>
<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>
@@ -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 %}

View File

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

View File

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