feat: добавлена интеграция синхронизации с Recommerce

This commit is contained in:
2026-01-12 21:45:31 +03:00
parent a5ab216934
commit 707b45b16d
13 changed files with 475 additions and 104 deletions

View File

@@ -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]]:
"""

View File

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

View File

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

View 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