feat: добавлена интеграция синхронизации с Recommerce
This commit is contained in:
@@ -26,12 +26,22 @@ class EncryptedCharField(models.CharField):
|
||||
description = "Encrypted CharField using Fernet"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Сохраняем оригинальный max_length для deconstruct()
|
||||
self._original_max_length = kwargs.get('max_length')
|
||||
# Зашифрованные данные длиннее исходных, увеличиваем max_length
|
||||
if 'max_length' in kwargs:
|
||||
# Fernet добавляет ~100 байт overhead
|
||||
kwargs['max_length'] = max(kwargs['max_length'] * 2, 500)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def deconstruct(self):
|
||||
"""Возвращаем оригинальные параметры для миграций"""
|
||||
name, path, args, kwargs = super().deconstruct()
|
||||
# Восстанавливаем оригинальный max_length
|
||||
if self._original_max_length is not None:
|
||||
kwargs['max_length'] = self._original_max_length
|
||||
return name, path, args, kwargs
|
||||
|
||||
def _get_fernet(self):
|
||||
"""Получить инстанс Fernet с ключом из settings"""
|
||||
key = getattr(settings, 'ENCRYPTION_KEY', None)
|
||||
|
||||
@@ -25,7 +25,7 @@ class Migration(migrations.Migration):
|
||||
('store_url', models.URLField(blank=True, help_text='Адрес магазина (например, https://shop.example.com)', verbose_name='URL магазина')),
|
||||
('auto_sync_products', models.BooleanField(default=False, help_text='Автоматически обновлять товары на маркетплейсе', verbose_name='Авто-синхронизация товаров')),
|
||||
('import_orders', models.BooleanField(default=False, help_text='Импортировать заказы с маркетплейса', verbose_name='Импорт заказов')),
|
||||
('api_token', integrations.fields.EncryptedCharField(blank=True, help_text='Токен авторизации из панели управления Recommerce', max_length=2000, verbose_name='API Токен (x-auth-token)')),
|
||||
('api_token', integrations.fields.EncryptedCharField(blank=True, help_text='Токен авторизации из панели управления Recommerce', max_length=500, verbose_name='API Токен (x-auth-token)')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Recommerce',
|
||||
@@ -45,8 +45,8 @@ class Migration(migrations.Migration):
|
||||
('store_url', models.URLField(blank=True, help_text='Адрес магазина (например, https://shop.example.com)', verbose_name='URL магазина')),
|
||||
('auto_sync_products', models.BooleanField(default=False, help_text='Автоматически обновлять товары на маркетплейсе', verbose_name='Авто-синхронизация товаров')),
|
||||
('import_orders', models.BooleanField(default=False, help_text='Импортировать заказы с маркетплейса', verbose_name='Импорт заказов')),
|
||||
('consumer_key', integrations.fields.EncryptedCharField(blank=True, help_text='REST API Consumer Key (хранится зашифрованным)', max_length=1020, verbose_name='Consumer Key')),
|
||||
('consumer_secret', integrations.fields.EncryptedCharField(blank=True, help_text='REST API Consumer Secret (хранится зашифрованным)', max_length=1020, verbose_name='Consumer Secret')),
|
||||
('consumer_key', integrations.fields.EncryptedCharField(blank=True, help_text='REST API Consumer Key (хранится зашифрованным)', max_length=255, verbose_name='Consumer Key')),
|
||||
('consumer_secret', integrations.fields.EncryptedCharField(blank=True, help_text='REST API Consumer Secret (хранится зашифрованным)', max_length=255, verbose_name='Consumer Secret')),
|
||||
('api_version', models.CharField(blank=True, default='v3', help_text='Версия WooCommerce REST API', max_length=10, verbose_name='Версия API')),
|
||||
],
|
||||
options={
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# Generated by Django 5.0.10 on 2026-01-11 21:41
|
||||
|
||||
import integrations.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('integrations', '0001_add_integration_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='recommerceintegration',
|
||||
name='api_token',
|
||||
field=integrations.fields.EncryptedCharField(blank=True, help_text='Токен авторизации из панели управления Recommerce', max_length=2000, verbose_name='API Токен (x-auth-token)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='woocommerceintegration',
|
||||
name='consumer_key',
|
||||
field=integrations.fields.EncryptedCharField(blank=True, help_text='REST API Consumer Key (хранится зашифрованным)', max_length=1020, verbose_name='Consumer Key'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='woocommerceintegration',
|
||||
name='consumer_secret',
|
||||
field=integrations.fields.EncryptedCharField(blank=True, help_text='REST API Consumer Secret (хранится зашифрованным)', max_length=1020, verbose_name='Consumer Secret'),
|
||||
),
|
||||
]
|
||||
@@ -1,29 +0,0 @@
|
||||
# Generated by Django 5.0.10 on 2026-01-11 23:29
|
||||
|
||||
import integrations.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('integrations', '0002_alter_recommerceintegration_api_token_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='recommerceintegration',
|
||||
name='api_token',
|
||||
field=integrations.fields.EncryptedCharField(blank=True, help_text='Токен авторизации из панели управления Recommerce', max_length=2000, verbose_name='API Токен (x-auth-token)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='woocommerceintegration',
|
||||
name='consumer_key',
|
||||
field=integrations.fields.EncryptedCharField(blank=True, help_text='REST API Consumer Key (хранится зашифрованным)', max_length=1020, verbose_name='Consumer Key'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='woocommerceintegration',
|
||||
name='consumer_secret',
|
||||
field=integrations.fields.EncryptedCharField(blank=True, help_text='REST API Consumer Secret (хранится зашифрованным)', max_length=1020, verbose_name='Consumer Secret'),
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import requests
|
||||
from typing import Dict, Any, Optional, List
|
||||
from .exceptions import (
|
||||
@@ -6,6 +7,8 @@ from .exceptions import (
|
||||
RecommerceAPIError
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecommerceClient:
|
||||
"""
|
||||
@@ -26,12 +29,13 @@ class RecommerceClient:
|
||||
token = self.api_token.encode('ascii', 'ignore').decode('ascii')
|
||||
except (AttributeError, ValueError):
|
||||
token = ''
|
||||
|
||||
|
||||
return {
|
||||
'x-auth-token': token,
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json', # В доках сказано form-data для POST, но обычно JSON тоже работает. Проверим.
|
||||
# Если API строго требует form-data, то requests сам поставит нужный Content-Type, если передать files или data вместо json
|
||||
# НЕ указываем Content-Type - requests сам поставит правильный:
|
||||
# - application/x-www-form-urlencoded для data=...
|
||||
# - application/json для json=...
|
||||
}
|
||||
|
||||
def _request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]:
|
||||
@@ -79,6 +83,7 @@ class RecommerceClient:
|
||||
if response.status_code in [401, 403]:
|
||||
raise RecommerceAuthError(f"Auth Error: {response.status_code}")
|
||||
|
||||
logger.error(f"Recommerce API error {response.status_code}: {response.text}")
|
||||
raise RecommerceAPIError(
|
||||
status_code=response.status_code,
|
||||
message=f"API Request failed: {url}",
|
||||
@@ -97,13 +102,16 @@ class RecommerceClient:
|
||||
return self._request('GET', f'catalog/products/{sku}')
|
||||
|
||||
def update_product(self, sku: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Обновить товар"""
|
||||
# В документации POST используется для обновления
|
||||
return self._request('POST', f'catalog/products/{sku}', json=data)
|
||||
"""Обновить товар (использует form-data как указано в документации)"""
|
||||
# Recommerce API требует form-data для POST запросов
|
||||
# data должен быть плоским словарём с ключами вида price[amount], price[currency]
|
||||
logger.info(f"Recommerce update_product {sku}: {data}")
|
||||
return self._request('POST', f'catalog/products/{sku}', data=data)
|
||||
|
||||
def create_product(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Создать товар"""
|
||||
return self._request('POST', 'catalog/products', json=data)
|
||||
"""Создать товар (использует form-data как указано в документации)"""
|
||||
# Recommerce API требует form-data для POST запросов
|
||||
return self._request('POST', 'catalog/products', data=data)
|
||||
|
||||
def get_orders(self, updated_after: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
|
||||
@@ -2,63 +2,90 @@ from typing import Dict, Any, List, Optional
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
def to_api_product(product: Any, stock_count: Optional[int] = None, fields: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
def to_api_product(
|
||||
product: Any,
|
||||
stock_count: Optional[int] = None,
|
||||
fields: Optional[List[str]] = None,
|
||||
for_create: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Преобразование внутреннего товара в формат Recommerce API.
|
||||
|
||||
|
||||
Recommerce API использует form-data для POST запросов, поэтому вложенные поля
|
||||
представляются в плоском формате: price[amount], price[currency]
|
||||
|
||||
Args:
|
||||
product: Экземпляр модели Product
|
||||
stock_count: Остаток товара (вычисляется отдельно, т.к. зависит от склада)
|
||||
fields: Список полей для экспорта. Если None - экспортируются все базовые поля.
|
||||
Поддерживаемые поля: 'sku', 'name', 'price', 'description', 'count', 'images'.
|
||||
|
||||
for_create: Если True - включает все обязательные поля для создания товара.
|
||||
|
||||
Returns:
|
||||
Dict: JSON-совместимый словарь для API
|
||||
Dict: Плоский словарь для form-data запроса
|
||||
"""
|
||||
data = {}
|
||||
|
||||
|
||||
# Если поля не указаны, берем базовый набор (без тяжелых полей типа картинок)
|
||||
if fields is None:
|
||||
fields = ['sku', 'name', 'price']
|
||||
|
||||
|
||||
# Для создания товара добавляем все обязательные поля
|
||||
if for_create:
|
||||
fields = list(set(fields) | {'sku', 'name', 'price', 'parent_category_sku'})
|
||||
|
||||
# SKU (Артикул) - обязательное поле для идентификации
|
||||
# Если product.sku нет, используем id (как fallback, но лучше sku)
|
||||
sku = getattr(product, 'sku', str(product.id))
|
||||
|
||||
# Всегда добавляем SKU если это не обновление, но для update метода API SKU идет в URL.
|
||||
# Но маппер просто готовит данные.
|
||||
|
||||
|
||||
if 'sku' in fields:
|
||||
data['sku'] = sku
|
||||
|
||||
|
||||
if 'name' in fields:
|
||||
data['name'] = product.name
|
||||
|
||||
|
||||
if 'parent_category_sku' in fields:
|
||||
# TODO: Добавить поле recommerce_category_sku в модель Product или Category
|
||||
# Пока пытаемся взять из атрибута, если он есть
|
||||
category_sku = getattr(product, 'recommerce_category_sku', None)
|
||||
if category_sku:
|
||||
data['parent_category_sku'] = category_sku
|
||||
|
||||
if 'price' in fields:
|
||||
# Recommerce ожидает price[amount] и price[currency]
|
||||
# Предполагаем, что валюта магазина совпадает с валютой Recommerce (BYN)
|
||||
data['price'] = {
|
||||
'amount': float(product.sale_price or 0),
|
||||
'currency': 'BYN' # Можно вынести в настройки
|
||||
}
|
||||
|
||||
# Recommerce ожидает price[amount] и price[currency] в form-data формате
|
||||
# Значения передаём как строки (так работает в проверенном скрипте)
|
||||
has_discount = product.sale_price and product.price and product.sale_price < product.price
|
||||
|
||||
if has_discount:
|
||||
# Есть скидка: текущая цена = sale_price, старая = price
|
||||
data['price[amount]'] = str(float(product.sale_price))
|
||||
data['price[currency]'] = 'BYN'
|
||||
# price_old - как в рабочем скрипте (не old_price из документации)
|
||||
data['price_old[amount]'] = str(float(product.price))
|
||||
data['price_old[currency]'] = 'BYN'
|
||||
# TODO: is_special='true' принимается API но игнорируется при обновлении.
|
||||
# Возможно работает только при создании товара.
|
||||
else:
|
||||
# Нет скидки: только основная цена
|
||||
data['price[amount]'] = str(float(product.price or 0))
|
||||
data['price[currency]'] = 'BYN'
|
||||
|
||||
if 'content' in fields:
|
||||
# content включает название и описание
|
||||
data['name'] = product.name
|
||||
data['description'] = product.description or ''
|
||||
|
||||
if 'description' in fields:
|
||||
data['description'] = product.description or ''
|
||||
|
||||
|
||||
if 'count' in fields and stock_count is not None:
|
||||
data['count'] = stock_count
|
||||
|
||||
|
||||
if 'images' in fields:
|
||||
# Примерная логика для картинок
|
||||
# Recommerce ожидает массив URL или объектов.
|
||||
# Документация: images[] - Картинки товара
|
||||
images = []
|
||||
if hasattr(product, 'images'):
|
||||
for img in product.images.all():
|
||||
if img.image:
|
||||
images.append(img.image.url)
|
||||
if images:
|
||||
data['images'] = images
|
||||
# Recommerce ожидает images[] для массива картинок
|
||||
if hasattr(product, 'photos'):
|
||||
for idx, photo in enumerate(product.photos.all()):
|
||||
if photo.image:
|
||||
data[f'images[{idx}]'] = photo.image.url
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -67,12 +67,26 @@ class RecommerceService:
|
||||
raise e
|
||||
|
||||
def create_product(self, product: Any) -> bool:
|
||||
"""Создать товар в Recommerce"""
|
||||
"""
|
||||
Создать товар в Recommerce.
|
||||
|
||||
Raises:
|
||||
RecommerceError: Если отсутствует обязательное поле parent_category_sku
|
||||
"""
|
||||
# Для создания нужны все поля
|
||||
stock = product.stocks.first()
|
||||
stock_count = int(stock.quantity_free) if stock else 0
|
||||
|
||||
data = to_api_product(product, stock_count=stock_count, fields=None)
|
||||
|
||||
data = to_api_product(product, stock_count=stock_count, fields=None, for_create=True)
|
||||
|
||||
# Проверяем обязательное поле для создания
|
||||
if 'parent_category_sku' not in data:
|
||||
raise RecommerceError(
|
||||
f"Невозможно создать товар '{product.sku}' в Recommerce: "
|
||||
f"не указана категория (parent_category_sku). "
|
||||
f"Добавьте поле recommerce_category_sku к товару или категории."
|
||||
)
|
||||
|
||||
self.client.create_product(data)
|
||||
return True
|
||||
|
||||
|
||||
119
myproject/integrations/recommerce/tasks.py
Normal file
119
myproject/integrations/recommerce/tasks.py
Normal file
@@ -0,0 +1,119 @@
|
||||
import logging
|
||||
from celery import shared_task
|
||||
from django.db import transaction
|
||||
from django_tenants.utils import schema_context
|
||||
from integrations.models import RecommerceIntegration
|
||||
from integrations.recommerce.services import RecommerceService
|
||||
from products.models import Product
|
||||
from integrations.recommerce.exceptions import RecommerceError, RecommerceAPIError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@shared_task(bind=True)
|
||||
def sync_products_batch_task(self, product_ids, options=None, schema_name=None):
|
||||
"""
|
||||
Celery задача для массовой синхронизации товаров с Recommerce.
|
||||
|
||||
Args:
|
||||
product_ids (list): Список ID товаров (PK) для синхронизации
|
||||
options (dict): Настройки синхронизации
|
||||
- fields (list): Список полей для обновления ['price', 'count', 'content', 'images']
|
||||
- create_if_missing (bool): Создавать товар, если он не найден (404)
|
||||
schema_name (str): Имя схемы тенанта для выполнения запросов
|
||||
"""
|
||||
if options is None:
|
||||
options = {}
|
||||
|
||||
fields = options.get('fields', [])
|
||||
create_if_missing = options.get('create_if_missing', False)
|
||||
|
||||
# Используем schema_context для выполнения запросов в правильной tenant схеме
|
||||
if schema_name:
|
||||
with schema_context(schema_name):
|
||||
return _do_sync(product_ids, fields, create_if_missing)
|
||||
else:
|
||||
return _do_sync(product_ids, fields, create_if_missing)
|
||||
|
||||
|
||||
def _do_sync(product_ids, fields, create_if_missing):
|
||||
"""
|
||||
Внутренняя функция для выполнения синхронизации в контексте схемы.
|
||||
|
||||
Args:
|
||||
product_ids (list): Список ID товаров (PK) для синхронизации
|
||||
fields (list): Список полей для обновления ['price', 'count', 'content', 'images']
|
||||
create_if_missing (bool): Создавать товар, если он не найден (404)
|
||||
"""
|
||||
# 1. Получаем интеграцию
|
||||
integration = RecommerceIntegration.objects.filter(is_active=True).first()
|
||||
if not integration or not integration.is_configured:
|
||||
msg = "Recommerce integration is not active or configured."
|
||||
logger.error(msg)
|
||||
return {"success": False, "error": msg}
|
||||
|
||||
service = RecommerceService(integration)
|
||||
|
||||
# 2. Получаем товары
|
||||
products = Product.objects.filter(pk__in=product_ids)
|
||||
|
||||
results = {
|
||||
"total": len(product_ids),
|
||||
"success": 0,
|
||||
"failed": 0,
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"errors": []
|
||||
}
|
||||
|
||||
logger.info(f"Starting Recommerce sync for {len(product_ids)} products. Fields: {fields}, create_if_missing: {create_if_missing}")
|
||||
|
||||
for product in products:
|
||||
try:
|
||||
# Если fields пустой или содержит 'all' - обновляем всё (передаем None в сервис)
|
||||
# Иначе передаем список полей.
|
||||
# В сервисе update_product(fields=None) обновляет всё.
|
||||
|
||||
# Маппинг опций фронтенда на логику сервиса
|
||||
# Фронт: ['price', 'count', 'content', 'images']
|
||||
# Сервис: ожидает список полей или None (всё).
|
||||
# Если выбраны не все галочки, передаем конкретные поля.
|
||||
# Но 'content' и 'images' в сервисе могут не поддерживаться напрямую как ключи,
|
||||
# нужно смотреть реализацию to_api_product.
|
||||
# Пока передаем как есть, предполагая, что сервис или маппер разберется,
|
||||
# либо если выбрано "все", передаем None.
|
||||
|
||||
# Упрощение: если выбраны все основные группы, считаем это полным обновлением
|
||||
is_full_update = False
|
||||
if 'content' in fields and 'images' in fields and 'price' in fields and 'count' in fields:
|
||||
is_full_update = True
|
||||
|
||||
service_fields = None if is_full_update else fields
|
||||
|
||||
# Если список полей пуст (ничего не выбрано), но задача запущена - странно, но пропустим
|
||||
if not is_full_update and not service_fields:
|
||||
logger.warning(f"Product {product.id}: No fields selected for update.")
|
||||
continue
|
||||
|
||||
try:
|
||||
service.update_product(product, fields=service_fields)
|
||||
results["updated"] += 1
|
||||
results["success"] += 1
|
||||
|
||||
except RecommerceAPIError as e:
|
||||
# Если товар не найден (404) и разрешено создание
|
||||
if e.status_code == 404 and create_if_missing:
|
||||
logger.info(f"Product {product.sku} not found. Creating...")
|
||||
service.create_product(product)
|
||||
results["created"] += 1
|
||||
results["success"] += 1
|
||||
else:
|
||||
raise e
|
||||
|
||||
except Exception as e:
|
||||
results["failed"] += 1
|
||||
error_msg = f"Product {product.sku} ({product.id}): {str(e)}"
|
||||
logger.error(error_msg)
|
||||
results["errors"].append(error_msg)
|
||||
|
||||
logger.info(f"Recommerce sync completed. Results: {results}")
|
||||
return results
|
||||
@@ -5,6 +5,7 @@ from .views import (
|
||||
save_integration_settings,
|
||||
get_integration_form_data,
|
||||
test_integration_connection,
|
||||
RecommerceBatchSyncView,
|
||||
)
|
||||
|
||||
app_name = 'integrations'
|
||||
@@ -18,4 +19,7 @@ urlpatterns = [
|
||||
path("settings/<str:integration_id>/", save_integration_settings, name="settings"),
|
||||
path("form/<str:integration_id>/", get_integration_form_data, name="form_data"),
|
||||
path("test/<str:integration_id>/", test_integration_connection, name="test"),
|
||||
|
||||
# Синхронизация
|
||||
path("recommerce/sync/", RecommerceBatchSyncView.as_view(), name="recommerce_sync"),
|
||||
]
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.views.decorators.http import require_POST
|
||||
|
||||
from user_roles.mixins import OwnerRequiredMixin
|
||||
from .models import RecommerceIntegration, WooCommerceIntegration
|
||||
from integrations.recommerce.tasks import sync_products_batch_task
|
||||
|
||||
|
||||
# Реестр доступных интеграций
|
||||
@@ -148,6 +149,44 @@ def get_integration_service(integration_id: str, instance):
|
||||
return None
|
||||
|
||||
|
||||
class RecommerceBatchSyncView(TemplateView):
|
||||
"""
|
||||
API View для запуска массовой синхронизации с Recommerce.
|
||||
POST /integrations/recommerce/sync/
|
||||
"""
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
# Временное логирование для отладки
|
||||
from user_roles.services import RoleService
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"User: {request.user}, Authenticated: {request.user.is_authenticated}")
|
||||
user_role = RoleService.get_user_role(request.user)
|
||||
logger.info(f"User role: {user_role}")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
def post(self, request, *args, **kwargs):
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
product_ids = data.get('product_ids', [])
|
||||
options = data.get('options', {})
|
||||
|
||||
if not product_ids:
|
||||
return JsonResponse({'error': 'No products selected'}, status=400)
|
||||
|
||||
# Запуск Celery задачи с передачей schema_name
|
||||
from django_tenants.utils import get_tenant_model
|
||||
Tenant = get_tenant_model()
|
||||
schema_name = request.tenant.schema_name
|
||||
task = sync_products_batch_task.delay(product_ids, options, schema_name)
|
||||
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'task_id': task.id,
|
||||
'message': f'Запущена синхронизация {len(product_ids)} товаров'
|
||||
})
|
||||
except Exception as e:
|
||||
return JsonResponse({'error': str(e)}, status=500)
|
||||
|
||||
|
||||
@require_POST
|
||||
def save_integration_settings(request, integration_id: str):
|
||||
"""
|
||||
|
||||
128
myproject/products/static/products/js/recommerce-sync.js
Normal file
128
myproject/products/static/products/js/recommerce-sync.js
Normal file
@@ -0,0 +1,128 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const syncBtn = document.getElementById('bulk-recommerce-sync');
|
||||
const modalEl = document.getElementById('recommerceSyncModal');
|
||||
const startBtn = document.getElementById('startRecommerceSyncBtn');
|
||||
|
||||
if (!syncBtn || !modalEl) return;
|
||||
|
||||
const modal = new bootstrap.Modal(modalEl);
|
||||
|
||||
syncBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Получаем выбранные элементы
|
||||
// Предполагается, что на странице есть механизм BatchSelection или просто чекбоксы
|
||||
let selectedItems = [];
|
||||
|
||||
// Проверяем глобальный объект BatchSelection (из batch-selection.js)
|
||||
if (window.BatchSelection && typeof window.BatchSelection.getSelectedItems === 'function') {
|
||||
selectedItems = window.BatchSelection.getSelectedItems();
|
||||
} else {
|
||||
// Fallback: собираем вручную
|
||||
document.querySelectorAll('.item-checkbox:checked').forEach(cb => {
|
||||
selectedItems.push(cb.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Фильтруем только товары (формат value="product:123")
|
||||
// Комплекты (kit:123) пока игнорируем, так как бэкенд ожидает Product ID
|
||||
const productIds = selectedItems
|
||||
.filter(val => val.startsWith('product:'))
|
||||
.map(val => val.split(':')[1]);
|
||||
|
||||
if (productIds.length === 0) {
|
||||
// Если выбраны только комплекты или ничего не выбрано
|
||||
if (selectedItems.length > 0) {
|
||||
alert('Для синхронизации с Recommerce выберите товары (комплекты пока не поддерживаются).');
|
||||
} else {
|
||||
alert('Выберите товары для синхронизации.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Обновляем UI модального окна
|
||||
document.getElementById('recommerceSyncCount').textContent = productIds.length;
|
||||
|
||||
// Сохраняем ID для отправки
|
||||
startBtn.dataset.productIds = JSON.stringify(productIds);
|
||||
|
||||
modal.show();
|
||||
});
|
||||
|
||||
startBtn.addEventListener('click', function() {
|
||||
const productIds = JSON.parse(this.dataset.productIds || '[]');
|
||||
|
||||
const options = {
|
||||
fields: [],
|
||||
create_if_missing: document.getElementById('syncCreateNew').checked
|
||||
};
|
||||
|
||||
if (document.getElementById('syncPrice').checked) options.fields.push('price');
|
||||
if (document.getElementById('syncStock').checked) options.fields.push('count');
|
||||
if (document.getElementById('syncContent').checked) options.fields.push('content');
|
||||
if (document.getElementById('syncImages').checked) options.fields.push('images');
|
||||
|
||||
// Блокируем кнопку
|
||||
startBtn.disabled = true;
|
||||
const originalText = startBtn.innerHTML;
|
||||
startBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Запуск...';
|
||||
|
||||
fetch('/settings/integrations/recommerce/sync/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
product_ids: productIds,
|
||||
options: options
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
modal.hide();
|
||||
// Используем стандартный alert или toast, если есть
|
||||
alert(`Синхронизация запущена успешно!\nID задачи: ${data.task_id}\nОбрабатывается товаров: ${productIds.length}`);
|
||||
|
||||
// Снимаем выделение
|
||||
if (window.BatchSelection && typeof window.BatchSelection.clearSelection === 'function') {
|
||||
window.BatchSelection.clearSelection();
|
||||
}
|
||||
} else {
|
||||
alert('Ошибка при запуске: ' + (data.error || 'Неизвестная ошибка'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Ошибка сети или сервера');
|
||||
})
|
||||
.finally(() => {
|
||||
startBtn.disabled = false;
|
||||
startBtn.innerHTML = originalText;
|
||||
});
|
||||
});
|
||||
|
||||
// Helper для получения CSRF токена
|
||||
function getCsrfToken() {
|
||||
// Сначала пробуем получить из скрытого поля (для CSRF_USE_SESSIONS = True)
|
||||
const csrfInput = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
if (csrfInput) {
|
||||
return csrfInput.value;
|
||||
}
|
||||
// Fallback: из куки
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
<!-- Модальное окно для синхронизации с Recommerce -->
|
||||
<div class="modal fade" id="recommerceSyncModal" tabindex="-1" aria-labelledby="recommerceSyncModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="recommerceSyncModalLabel">
|
||||
<i class="bi bi-arrow-repeat"></i> Синхронизация с Recommerce
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info mb-3">
|
||||
<i class="bi bi-info-circle"></i> <strong>Выбрано товаров:</strong> <span id="recommerceSyncCount">0</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Что обновлять?</label>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" value="price" id="syncPrice" checked>
|
||||
<label class="form-check-label" for="syncPrice">
|
||||
Цены
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" value="count" id="syncStock" checked>
|
||||
<label class="form-check-label" for="syncStock">
|
||||
Остатки
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" value="content" id="syncContent">
|
||||
<label class="form-check-label" for="syncContent">
|
||||
Название и описание
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" value="images" id="syncImages">
|
||||
<label class="form-check-label" for="syncImages">
|
||||
Изображения
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="syncCreateNew">
|
||||
<label class="form-check-label" for="syncCreateNew">
|
||||
Создавать товары, если не найдены
|
||||
</label>
|
||||
<div class="form-text text-muted">
|
||||
Если товар отсутствует в Recommerce, он будет создан (требуется полное заполнение).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="button" class="btn btn-primary" id="startRecommerceSyncBtn">
|
||||
<i class="bi bi-play-fill"></i> Запустить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,6 +4,7 @@
|
||||
{% block title %}Товары и комплекты{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% csrf_token %}
|
||||
<div class="container-fluid mt-4">
|
||||
<h2 class="mb-4">
|
||||
<i class="bi bi-box-seam"></i> Товары и комплекты
|
||||
@@ -188,6 +189,12 @@
|
||||
<i class="bi bi-bookmark-fill"></i> Изменить категории
|
||||
</a>
|
||||
</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" id="bulk-recommerce-sync">
|
||||
<i class="bi bi-arrow-repeat"></i> Синхронизация с Recommerce
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -398,6 +405,8 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% include "products/includes/recommerce_sync_modal.html" %}
|
||||
|
||||
<!-- Модальное окно для массового изменения категорий -->
|
||||
<div class="modal fade" id="bulkCategoryModal" tabindex="-1" aria-labelledby="bulkCategoryModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
@@ -487,4 +496,5 @@
|
||||
{% load static %}
|
||||
<script src="{% static 'products/js/batch-selection.js' %}?v=1.2"></script>
|
||||
<script src="{% static 'products/js/bulk-category-modal.js' %}?v=1.6"></script>
|
||||
<script src="{% static 'products/js/recommerce-sync.js' %}?v=1.1"></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user