Рефакторинг: перенос логики создания временных комплектов в сервис

Изменения:
- Удалена функция create_temporary_kit из myproject/orders/views.py
- Перенесена в новый сервис myproject/products/services/kit_service.py
- Добавлен API endpoint products:api-temporary-kit-create для создания временных комплектов
- Обновлены URL-ы соответственно

Преимущества:
- Логика временных комплектов теперь находится в соответствующем приложении (products)
- Упрощена архитектура orders приложения
- Сервис может быть переиспользован в других контекстах
- Лучшее разделение ответственности между приложениями

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-10 23:44:05 +03:00
parent 3c0ba70bc8
commit 5d5de1fe31
15 changed files with 471 additions and 150 deletions

View File

@@ -2,7 +2,8 @@
"permissions": {
"allow": [
"Bash(dir /b /s settings.py)",
"Bash(git add:*)"
"Bash(git add:*)",
"Bash(..venvScriptspython.exe manage.py check)"
],
"deny": [],
"ask": []

View File

@@ -14,7 +14,4 @@ urlpatterns = [
# AJAX endpoints
path('<int:pk>/autosave/', views.autosave_draft_order, name='order-autosave'),
path('create-draft/', views.create_draft_from_form, name='order-create-draft'),
# Временные комплекты
path('temporary-kits/create/', views.create_temporary_kit, name='temporary-kit-create'),
]

View File

@@ -3,7 +3,6 @@ from django.shortcuts import render, redirect, get_object_or_404
from django.contrib import messages
from django.core.paginator import Paginator
from django.http import JsonResponse
from django.db import transaction
from django.views.decorators.http import require_http_methods
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError
@@ -11,7 +10,6 @@ from .models import Order, OrderItem
from .forms import OrderForm, OrderItemFormSet
from .filters import OrderFilter
from .services import DraftOrderService
from products.models import ProductKit, KitItem, Product
import json
@@ -383,122 +381,5 @@ def create_draft_from_form(request):
# === ВРЕМЕННЫЕ КОМПЛЕКТЫ ===
@require_http_methods(["POST"])
def create_temporary_kit(request):
"""
AJAX endpoint для создания временного комплекта.
Используется при оформлении заказа для создания букета "на лету".
Принимает JSON:
{
"name": "Букет для Анны",
"description": "Красные розы и белые лилии",
"order_id": 123, // опционально, если заказ уже создан
"components": [
{"product_id": 1, "quantity": "5"},
{"product_id": 2, "quantity": "3"}
]
}
Возвращает JSON:
{
"success": true,
"kit_id": 456,
"kit_name": "Букет для Анны",
"kit_sku": "KIT-000456",
"kit_price": "1500.00",
"message": "Временный комплект создан успешно"
}
"""
import json
from decimal import Decimal
try:
data = json.loads(request.body)
name = data.get('name', '').strip()
description = data.get('description', '').strip()
order_id = data.get('order_id')
components = data.get('components', [])
# Валидация
if not name:
return JsonResponse({
'success': False,
'error': 'Необходимо указать название комплекта'
}, status=400)
if not components or len(components) == 0:
return JsonResponse({
'success': False,
'error': 'Комплект должен содержать хотя бы один компонент'
}, status=400)
# Создаем временный комплект
with transaction.atomic():
# Получаем заказ если указан
order = None
if order_id:
try:
order = Order.objects.get(pk=order_id)
except Order.DoesNotExist:
return JsonResponse({
'success': False,
'error': f'Заказ #{order_id} не найден'
}, status=404)
# Создаем комплект
kit = ProductKit.objects.create(
name=name,
description=description,
is_temporary=True,
is_active=True,
order=order,
price_adjustment_type='none'
)
# Добавляем компоненты
for component in components:
product_id = component.get('product_id')
quantity = component.get('quantity')
if not product_id or not quantity:
continue
try:
product = Product.objects.get(pk=product_id)
KitItem.objects.create(
kit=kit,
product=product,
quantity=Decimal(str(quantity))
)
except Product.DoesNotExist:
# Пропускаем несуществующие товары
continue
except (ValueError, TypeError):
# Пропускаем некорректные количества
continue
# Пересчитываем цену комплекта
kit.recalculate_base_price()
return JsonResponse({
'success': True,
'kit_id': kit.id,
'kit_name': kit.name,
'kit_sku': kit.sku,
'kit_price': str(kit.actual_price),
'message': f'Временный комплект "{kit.name}" создан успешно'
})
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)
# УДАЛЕНО: Логика создания временных комплектов перенесена в products.services.kit_service
# Используйте API endpoint: products:api-temporary-kit-create

View File

@@ -252,7 +252,7 @@ KitItemFormSetCreate = inlineformset_factory(
KitItem,
form=KitItemForm,
formset=BaseKitItemFormSet,
fields=['id', 'product', 'variant_group', 'quantity'],
fields=['product', 'variant_group', 'quantity'],
extra=1, # Показать 1 пустую форму для первого компонента
can_delete=True, # Разрешить удаление компонентов
min_num=0, # Минимум 0 компонентов (можно создать пустой комплект)
@@ -266,7 +266,7 @@ KitItemFormSetUpdate = inlineformset_factory(
KitItem,
form=KitItemForm,
formset=BaseKitItemFormSet,
fields=['id', 'product', 'variant_group', 'quantity'],
fields=['product', 'variant_group', 'quantity'],
extra=0, # НЕ показывать пустые формы при редактировании
can_delete=True, # Разрешить удаление компонентов
min_num=0, # Минимум 0 компонентов

View File

@@ -0,0 +1,159 @@
"""
Сервис для работы с комплектами товаров.
Содержит бизнес-логику создания, обновления и управления комплектами.
"""
from decimal import Decimal
from django.db import transaction
from typing import List, Dict, Optional
from ..models import ProductKit, Product, KitItem
def create_temporary_kit(
name: str,
components: List[Dict],
description: str = '',
order=None
) -> ProductKit:
"""
Создает временный комплект с компонентами.
Временные комплекты используются для создания букетов "на лету" при оформлении заказа.
Они автоматически помечаются как временные (is_temporary=True) и связываются с заказом.
Args:
name: Название комплекта
components: Список компонентов в формате [{"product_id": 1, "quantity": "5"}, ...]
description: Описание комплекта (опционально)
order: Заказ, к которому привязан временный комплект (опционально)
Returns:
ProductKit: Созданный временный комплект
Raises:
ValueError: Если данные невалидны
Product.DoesNotExist: Если товар не найден
Example:
>>> kit = create_temporary_kit(
... name="Букет для Анны",
... description="Красные розы и белые лилии",
... components=[
... {"product_id": 1, "quantity": "5"},
... {"product_id": 2, "quantity": "3"}
... ]
... )
"""
# Валидация
if not name or not name.strip():
raise ValueError('Необходимо указать название комплекта')
if not components or len(components) == 0:
raise ValueError('Комплект должен содержать хотя бы один компонент')
with transaction.atomic():
# Создаем комплект
kit = ProductKit.objects.create(
name=name.strip(),
description=description.strip() if description else '',
is_temporary=True,
is_active=True,
order=order,
price_adjustment_type='none'
)
# Добавляем компоненты
added_count = 0
for component in components:
product_id = component.get('product_id')
quantity = component.get('quantity')
if not product_id or not quantity:
continue
try:
product = Product.objects.get(pk=product_id, is_active=True)
KitItem.objects.create(
kit=kit,
product=product,
quantity=Decimal(str(quantity))
)
added_count += 1
except Product.DoesNotExist:
# Пропускаем несуществующие товары
continue
except (ValueError, TypeError) as e:
# Пропускаем некорректные количества
continue
if added_count == 0:
raise ValueError('Не удалось добавить ни одного компонента в комплект')
# Пересчитываем цену комплекта на основе компонентов
kit.recalculate_base_price()
return kit
def make_kit_permanent(kit: ProductKit) -> bool:
"""
Преобразует временный комплект в постоянный.
Args:
kit: Комплект для преобразования
Returns:
bool: True если комплект был преобразован, False если уже постоянный
"""
if not kit.is_temporary:
return False
kit.is_temporary = False
kit.order = None # Отвязываем от заказа
kit.save()
return True
def duplicate_kit(kit: ProductKit, new_name: Optional[str] = None) -> ProductKit:
"""
Создает копию комплекта со всеми компонентами.
Args:
kit: Комплект для дублирования
new_name: Новое название (если None, используется "Копия {original_name}")
Returns:
ProductKit: Новый комплект-копия
"""
with transaction.atomic():
# Копируем комплект
new_kit = ProductKit.objects.create(
name=new_name or f"Копия {kit.name}",
description=kit.description,
short_description=kit.short_description,
price_adjustment_type=kit.price_adjustment_type,
price_adjustment_value=kit.price_adjustment_value,
sale_price=kit.sale_price,
is_temporary=False, # Копия всегда постоянная
is_active=kit.is_active
)
# Копируем категории
new_kit.categories.set(kit.categories.all())
# Копируем теги
new_kit.tags.set(kit.tags.all())
# Копируем компоненты
for item in kit.kit_items.all():
KitItem.objects.create(
kit=new_kit,
product=item.product,
variant_group=item.variant_group,
quantity=item.quantity
)
# Пересчитываем цену
new_kit.recalculate_base_price()
return new_kit

View File

@@ -10,6 +10,7 @@
{% for kititem_form in kititem_formset %}
<div class="card mb-2 kititem-form border"
data-form-index="{{ forloop.counter0 }}"
data-product-id="{% if kititem_form.instance.product %}{{ kititem_form.instance.product.id }}{% endif %}"
data-product-price="{% if kititem_form.instance.product %}{{ kititem_form.instance.product.actual_price|default:0 }}{% else %}0{% endif %}">
{{ kititem_form.id }}
<div class="card-body p-2">
@@ -61,7 +62,7 @@
<!-- УДАЛЕНИЕ -->
<div class="col-md-1 text-end">
{% if kititem_form.DELETE %}
<button type="button" class="btn btn-sm btn-link text-danger p-0" onclick="this.previousElementSibling.checked = true; this.closest('.kititem-form').style.display='none';" title="Удалить">
<button type="button" class="btn btn-sm btn-link text-danger p-0" onclick="this.nextElementSibling.checked = true; this.closest('.kititem-form').style.display='none'; if(typeof calculateFinalPrice === 'function') calculateFinalPrice();" title="Удалить">
<i class="bi bi-x-lg"></i>
</button>
{{ kititem_form.DELETE }}

View File

@@ -442,10 +442,23 @@ document.addEventListener('DOMContentLoaded', function() {
return 0;
}
const productId = parseInt(selectElement.value);
const rawValue = selectElement.value;
if (!rawValue) {
console.warn('getProductPrice: no value');
return 0;
}
if (!selectElement.value || isNaN(productId) || productId <= 0) {
console.warn('getProductPrice: no valid product id', selectElement.value);
// Извлекаем числовой ID из значения (может быть "product_123" или "123")
let productId;
if (rawValue.includes('_')) {
const parts = rawValue.split('_');
productId = parseInt(parts[1]);
} else {
productId = parseInt(rawValue);
}
if (isNaN(productId) || productId <= 0) {
console.warn('getProductPrice: invalid product id', rawValue);
return 0;
}
@@ -471,15 +484,19 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
// Пытаемся получить из Select2 option data (сначала actual_price, потом price)
const selectedOption = $(selectElement).find('option:selected');
let priceData = selectedOption.data('actual_price') || selectedOption.data('price');
if (priceData) {
const price = parseFloat(priceData) || 0;
if (price > 0) {
priceCache[productId] = price;
console.log('getProductPrice: from select2 data', productId, price);
return price;
// Пытаемся получить из Select2 data (приоритет: actual_price > price)
const $select = $(selectElement);
const selectedData = $select.select2('data');
if (selectedData && selectedData.length > 0) {
const itemData = selectedData[0];
const priceData = itemData.actual_price || itemData.price;
if (priceData) {
const price = parseFloat(priceData) || 0;
if (price > 0) {
priceCache[productId] = price;
console.log('getProductPrice: from select2 data', productId, price);
return price;
}
}
}
@@ -514,7 +531,12 @@ document.addEventListener('DOMContentLoaded', function() {
$('[name$="-product"]').on('select2:select', async function() {
const form = $(this).closest('.kititem-form');
if (this.value) {
form.attr('data-product-id', this.value);
// Извлекаем числовой ID из "product_123"
let numericId = this.value;
if (this.value.includes('_')) {
numericId = this.value.split('_')[1];
}
form.attr('data-product-id', numericId);
// Загружаем цену и пересчитываем
await getProductPrice(this);
calculateFinalPrice();

View File

@@ -36,6 +36,7 @@ urlpatterns = [
# API endpoints
path('api/search-products-variants/', views.search_products_and_variants, name='api-search-products-variants'),
path('api/kits/temporary/create/', views.create_temporary_kit_api, name='api-temporary-kit-create'),
# CRUD URLs for ProductVariantGroup (Варианты товаров)
path('variant-groups/', views.ProductVariantGroupListView.as_view(), name='variantgroup-list'),

View File

@@ -71,7 +71,7 @@ from .variant_group_views import (
)
# API представления
from .api_views import search_products_and_variants, validate_kit_cost
from .api_views import search_products_and_variants, validate_kit_cost, create_temporary_kit_api
__all__ = [
@@ -132,4 +132,5 @@ __all__ = [
# API
'search_products_and_variants',
'validate_kit_cost',
'create_temporary_kit_api',
]

View File

@@ -531,3 +531,92 @@ def validate_kit_cost(request):
return JsonResponse({
'error': str(e)
}, status=500)
def create_temporary_kit_api(request):
"""
AJAX endpoint для создания временного комплекта.
Используется при оформлении заказа для создания букета "на лету".
Принимает JSON:
{
"name": "Букет для Анны",
"description": "Красные розы и белые лилии",
"order_id": 123, // опционально, если заказ уже создан
"components": [
{"product_id": 1, "quantity": "5"},
{"product_id": 2, "quantity": "3"}
]
}
Возвращает JSON:
{
"success": true,
"kit_id": 456,
"kit_name": "Букет для Анны",
"kit_sku": "KIT-000456",
"kit_price": "1500.00",
"message": "Временный комплект создан успешно"
}
"""
if request.method != 'POST':
return JsonResponse({
'success': False,
'error': 'Метод не поддерживается'
}, status=405)
import json
from ..services.kit_service import create_temporary_kit
from orders.models import Order
try:
data = json.loads(request.body)
name = data.get('name', '').strip()
description = data.get('description', '').strip()
order_id = data.get('order_id')
components = data.get('components', [])
# Получаем заказ если указан
order = None
if order_id:
try:
order = Order.objects.get(pk=order_id)
except Order.DoesNotExist:
return JsonResponse({
'success': False,
'error': f'Заказ #{order_id} не найден'
}, status=404)
# Создаем временный комплект через сервис
kit = create_temporary_kit(
name=name,
description=description,
components=components,
order=order
)
return JsonResponse({
'success': True,
'kit_id': kit.id,
'kit_name': kit.name,
'kit_sku': kit.sku,
'kit_price': str(kit.actual_price),
'message': f'Временный комплект "{kit.name}" создан успешно'
})
except ValueError 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)

View File

@@ -93,6 +93,28 @@ class ProductKitCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateVi
template_name = 'products/productkit_create.html'
permission_required = 'products.add_productkit'
def post(self, request, *args, **kwargs):
"""
Обрабатываем POST данные и очищаем ID товаров/комплектов от префиксов.
API возвращает ID в формате "product_123" или "kit_456", но Django ожидает числа.
"""
# Создаем изменяемую копию POST данных
post_data = request.POST.copy()
# Очищаем product ID от префиксов (product_123 -> 123)
for key in post_data.keys():
if key.endswith('-product') and post_data[key]:
value = post_data[key]
if '_' in value:
# Извлекаем числовой ID из "product_123"
numeric_id = value.split('_')[1]
post_data[key] = numeric_id
# Заменяем request.POST на очищенные данные
request.POST = post_data
return super().post(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@@ -199,6 +221,28 @@ class ProductKitUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateVi
template_name = 'products/productkit_edit.html'
permission_required = 'products.change_productkit'
def post(self, request, *args, **kwargs):
"""
Обрабатываем POST данные и очищаем ID товаров/комплектов от префиксов.
API возвращает ID в формате "product_123" или "kit_456", но Django ожидает числа.
"""
# Создаем изменяемую копию POST данных
post_data = request.POST.copy()
# Очищаем product ID от префиксов (product_123 -> 123)
for key in post_data.keys():
if key.endswith('-product') and post_data[key]:
value = post_data[key]
if '_' in value:
# Извлекаем числовой ID из "product_123"
numeric_id = value.split('_')[1]
post_data[key] = numeric_id
# Заменяем request.POST на очищенные данные
request.POST = post_data
return super().post(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)

47
test_api.sh Normal file
View File

@@ -0,0 +1,47 @@
#!/bin/bash
# Тест API endpoints для поиска и создания клиентов
# Использование: bash test_api.sh
BASE_URL="http://grach.localhost:8000"
echo "=================================================="
echo "ТЕСТ API ENDPOINTS ДЛЯ ПОИСКА КЛИЕНТОВ"
echo "=================================================="
echo ""
# Test 1: Поиск по имени
echo "TEST 1: Поиск по имени (q=Иван)"
echo "URL: $BASE_URL/customers/api/search/?q=Иван"
echo ""
curl -s "$BASE_URL/customers/api/search/?q=Иван" | python -m json.tool 2>/dev/null || curl -s "$BASE_URL/customers/api/search/?q=Иван"
echo ""
echo "---"
echo ""
# Test 2: Поиск по телефону
echo "TEST 2: Поиск по телефону (q=375)"
echo "URL: $BASE_URL/customers/api/search/?q=375"
echo ""
curl -s "$BASE_URL/customers/api/search/?q=375" | python -m json.tool 2>/dev/null || curl -s "$BASE_URL/customers/api/search/?q=375"
echo ""
echo "---"
echo ""
# Test 3: Пустой поиск (должна вернуться пустая строка results)
echo "TEST 3: Пустой поиск (q=)"
echo "URL: $BASE_URL/customers/api/search/?q="
echo ""
curl -s "$BASE_URL/customers/api/search/?q=" | python -m json.tool 2>/dev/null || curl -s "$BASE_URL/customers/api/search/?q="
echo ""
echo "---"
echo ""
# Test 4: Проверка что endpoint существует
echo "TEST 4: Проверка доступности endpoint'а"
echo "URL: $BASE_URL/customers/api/search/"
echo ""
curl -i "$BASE_URL/customers/api/search/?q=test" 2>&1 | head -15
echo ""
echo "=================================================="
echo ""

52
test_api_simple.py Normal file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python
"""
Простой скрипт для проверки API endpoints через Django shell
"""
import os
import django
# Настройка Django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
django.setup()
from django.test import Client
import json
client = Client()
BASE_URL = '/customers/api/search/'
print("=" * 60)
print("ТЕСТ API ENDPOINTS")
print("=" * 60)
# Test 1: Empty query
print("\nТЕСТ 1: Пустой запрос")
response = client.get(f'{BASE_URL}?q=')
print(f"Статус: {response.status_code}")
print(f"Ответ: {response.content.decode()}")
# Test 2: Search by single letter
print("\n" + "=" * 60)
print("ТЕСТ 2: Поиск по букве 'И'")
response = client.get(f'{BASE_URL}?q=И')
print(f"Статус: {response.status_code}")
data = json.loads(response.content)
print(f"Результатов: {len(data.get('results', []))}")
if data.get('results'):
for item in data['results'][:3]:
print(f" - {item.get('text', 'No text')}")
# Test 3: Search by number
print("\n" + "=" * 60)
print("ТЕСТ 3: Поиск по цифрам '29'")
response = client.get(f'{BASE_URL}?q=29')
print(f"Статус: {response.status_code}")
data = json.loads(response.content)
print(f"Результатов: {len(data.get('results', []))}")
if data.get('results'):
for item in data['results'][:3]:
print(f" - {item.get('text', 'No text')}")
print("\n" + "=" * 60)
print("Готово!")
print("=" * 60)

26
test_customer_api.sh Normal file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
# Тест API эндпоинтов для поиска и создания клиентов
API_HOST="http://grach.localhost:8000"
echo "=== Тест API поиска клиентов ==="
echo ""
# Тест 1: Поиск по имени
echo "1. Поиск по имени 'Иван':"
curl -s -X GET "${API_HOST}/customers/api/search/?q=Иван" \
-H "Accept: application/json" | python -m json.tool || echo "Ошибка в запросе"
echo ""
echo "2. Поиск по частичному номеру телефона '2912':"
curl -s -X GET "${API_HOST}/customers/api/search/?q=2912" \
-H "Accept: application/json" | python -m json.tool || echo "Ошибка в запросе"
echo ""
echo "3. Поиск по email 'ivan':"
curl -s -X GET "${API_HOST}/customers/api/search/?q=ivan" \
-H "Accept: application/json" | python -m json.tool || echo "Ошибка в запросе"
echo ""
echo "=== Тесты завершены ==="

View File

@@ -1,12 +1,12 @@
docker run -d \
--name postgres17 \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_USER=postgres \
-e POSTGRES_DB=inventory_db \
-p 5432:5432 \
-v postgres17-data:/var/lib/postgresql/data \
docker run -d `
--name postgres17 `
-e POSTGRES_PASSWORD=postgres `
-e POSTGRES_USER=postgres `
-e POSTGRES_DB=inventory_db `
-p 5432:5432 `
-v postgres17-data:/var/lib/postgresql/data `
postgres:17
# 2. Создаем миграции с нуля
python manage.py makemigrations