Описание
++ {cleanProductDescription(product.description)} +
+ {cleanProductDescription(product.description).length > 420 ? ( + + ) : null} +diff --git a/README.md b/README.md
index 02ee1944..5e8e934a 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,7 @@ and [`frontend/package.json`](frontend/package.json).
- [LDAP and Active Directory](docs/guides/LDAP.md)
- [WebSocket and realtime](docs/guides/WEBSOCKET.md)
- [Notifications](docs/guides/NOTIFICATIONS.md)
+- [Commerce and MoySklad](docs/guides/COMMERCE.md)
With the backend running, generated contracts are available at:
diff --git a/backend/api/v1/commerce/serializers.py b/backend/api/v1/commerce/serializers.py
index 88fb5e8c..82e9cb05 100644
--- a/backend/api/v1/commerce/serializers.py
+++ b/backend/api/v1/commerce/serializers.py
@@ -10,6 +10,7 @@ from commerce.models import (
IntegrationSyncRun,
Product,
ProductBinding,
+ ProductImage,
Sale,
SaleLine,
SourceSystem,
@@ -45,6 +46,9 @@ class IntegrationSyncRunSerializer(serializers.ModelSerializer):
"created_products",
"matched_products",
"stock_snapshots",
+ "images",
+ "image_errors",
+ "sales",
"error",
"started_at",
"finished_at",
@@ -202,6 +206,7 @@ class ProductBindingSerializer(serializers.ModelSerializer):
"source_code",
"external_id",
"external_sku",
+ "metadata",
"is_primary",
"created_at",
"updated_at",
@@ -209,8 +214,28 @@ class ProductBindingSerializer(serializers.ModelSerializer):
read_only_fields = ["id", "created_at", "updated_at"]
+class ProductImageSerializer(serializers.ModelSerializer):
+ url = serializers.ImageField(source="file", read_only=True)
+ thumbnail_url = serializers.ImageField(source="thumbnail", read_only=True)
+
+ class Meta:
+ model = ProductImage
+ fields = [
+ "id",
+ "url",
+ "thumbnail_url",
+ "filename",
+ "title",
+ "source_size",
+ "position",
+ "is_primary",
+ "source_updated_at",
+ ]
+
+
class ProductSerializer(serializers.ModelSerializer):
bindings = ProductBindingSerializer(many=True, read_only=True)
+ images = ProductImageSerializer(many=True, read_only=True)
variants_count = serializers.IntegerField(read_only=True)
class Meta:
@@ -226,6 +251,7 @@ class ProductSerializer(serializers.ModelSerializer):
"is_active",
"variants_count",
"bindings",
+ "images",
"created_at",
"updated_at",
]
diff --git a/backend/api/v1/commerce/viewsets.py b/backend/api/v1/commerce/viewsets.py
index 4f78cc82..5c1c54fc 100644
--- a/backend/api/v1/commerce/viewsets.py
+++ b/backend/api/v1/commerce/viewsets.py
@@ -1,6 +1,8 @@
+import logging
from django.utils import timezone
from datetime import timedelta
+from django.db.models.deletion import ProtectedError
from django.db.models import Count
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, viewsets
@@ -45,6 +47,9 @@ from .serializers import (
)
+logger = logging.getLogger(__name__)
+
+
class IntegrationConnectionViewSet(viewsets.ModelViewSet):
queryset = IntegrationConnection.objects.select_related("source")
serializer_class = IntegrationConnectionSerializer
@@ -57,6 +62,16 @@ class IntegrationConnectionViewSet(viewsets.ModelViewSet):
try:
context = test_moysklad_connection(connection)
except (MoySkladError, CredentialError) as exc:
+ logger.warning(
+ "Commerce connection test failed",
+ extra={
+ "event": "commerce.connection.test_failed",
+ "connection_id": str(connection.pk),
+ "provider": connection.provider,
+ "error_type": type(exc).__name__,
+ "failure_reason": str(exc),
+ },
+ )
connection.status = IntegrationStatus.ERROR
connection.last_error = str(exc)
connection.last_tested_at = timezone.now()
@@ -93,6 +108,14 @@ class IntegrationConnectionViewSet(viewsets.ModelViewSet):
"updated_at",
]
)
+ logger.info(
+ "Commerce connection test succeeded",
+ extra={
+ "event": "commerce.connection.test_succeeded",
+ "connection_id": str(connection.pk),
+ "provider": connection.provider,
+ },
+ )
return Response(self.get_serializer(connection).data)
@action(detail=True, methods=["post"])
@@ -132,6 +155,34 @@ class SourceSystemViewSet(viewsets.ModelViewSet):
ordering_fields = ["code", "name", "created_at"]
ordering = ["name"]
+ def destroy(self, request, *args, **kwargs):
+ source = self.get_object()
+ try:
+ self.perform_destroy(source)
+ except ProtectedError as exc:
+ protected_count = len(exc.protected_objects)
+ logger.info(
+ "Commerce source deletion rejected because it is in use",
+ extra={
+ "event": "commerce.source.delete_conflict",
+ "source_id": source.pk,
+ "source_code": source.code,
+ "protected_count": protected_count,
+ },
+ )
+ return Response(
+ {
+ "code": "source_in_use",
+ "detail": (
+ "Источник используется подключением или импортированными "
+ "данными. Сначала удалите связанные записи."
+ ),
+ "protected_count": protected_count,
+ },
+ status=status.HTTP_409_CONFLICT,
+ )
+ return Response(status=status.HTTP_204_NO_CONTENT)
+
class ProductViewSet(viewsets.ModelViewSet):
queryset = Product.objects.all()
@@ -152,7 +203,7 @@ class ProductViewSet(viewsets.ModelViewSet):
super()
.get_queryset()
.select_related("parent")
- .prefetch_related("bindings__source")
+ .prefetch_related("bindings__source", "images")
.annotate(variants_count=Count("variants", distinct=True))
.distinct()
)
@@ -232,8 +283,13 @@ class CostViewSet(viewsets.ModelViewSet):
queryset = Cost.objects.select_related("source", "product", "sale_line")
serializer_class = CostSerializer
permission_classes = [CommerceAccessPermission]
- filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
+ filter_backends = [
+ DjangoFilterBackend,
+ filters.SearchFilter,
+ filters.OrderingFilter,
+ ]
filterset_fields = ["source", "product", "channel", "cost_type", "currency"]
+ search_fields = ["external_id", "channel", "product__sku", "product__name"]
ordering_fields = ["occurred_at", "amount", "created_at"]
ordering = ["-occurred_at"]
@@ -242,7 +298,17 @@ class StockSnapshotViewSet(viewsets.ModelViewSet):
queryset = StockSnapshot.objects.select_related("source", "product")
serializer_class = StockSnapshotSerializer
permission_classes = [CommerceAccessPermission]
- filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
+ filter_backends = [
+ DjangoFilterBackend,
+ filters.SearchFilter,
+ filters.OrderingFilter,
+ ]
filterset_fields = ["source", "product", "warehouse_external_id"]
+ search_fields = [
+ "warehouse_external_id",
+ "warehouse_name",
+ "product__sku",
+ "product__name",
+ ]
ordering_fields = ["captured_at", "quantity", "created_at"]
ordering = ["-captured_at"]
diff --git a/backend/commerce/admin.py b/backend/commerce/admin.py
index fe03b6c9..ebc95d15 100644
--- a/backend/commerce/admin.py
+++ b/backend/commerce/admin.py
@@ -6,6 +6,7 @@ from .models import (
IntegrationSyncRun,
Product,
ProductBinding,
+ ProductImage,
Sale,
SaleLine,
SourceRecord,
@@ -34,13 +35,27 @@ class ProductBindingInline(admin.TabularInline):
autocomplete_fields = ["source"]
+class ProductImageInline(admin.TabularInline):
+ model = ProductImage
+ extra = 0
+ readonly_fields = ["file", "thumbnail", "source_updated_at"]
+
+
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ["sku", "name", "kind", "parent", "is_active", "updated_at"]
list_filter = ["kind", "is_active"]
search_fields = ["sku", "name", "barcode", "bindings__external_sku"]
autocomplete_fields = ["parent"]
- inlines = [ProductBindingInline]
+ inlines = [ProductBindingInline, ProductImageInline]
+
+
+@admin.register(ProductImage)
+class ProductImageAdmin(admin.ModelAdmin):
+ list_display = ["product", "source", "filename", "is_primary", "position"]
+ list_filter = ["source", "is_primary"]
+ search_fields = ["product__sku", "product__name", "filename", "external_id"]
+ autocomplete_fields = ["product", "source"]
@admin.register(SourceSystem)
diff --git a/backend/commerce/apps.py b/backend/commerce/apps.py
index db7b5261..1845bcdb 100644
--- a/backend/commerce/apps.py
+++ b/backend/commerce/apps.py
@@ -5,3 +5,6 @@ class CommerceConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "commerce"
verbose_name = "Товарная аналитика"
+
+ def ready(self):
+ from . import signals # noqa: F401
diff --git a/backend/commerce/integrations/moysklad.py b/backend/commerce/integrations/moysklad.py
index 4f732384..0c067891 100644
--- a/backend/commerce/integrations/moysklad.py
+++ b/backend/commerce/integrations/moysklad.py
@@ -1,5 +1,8 @@
from collections.abc import Iterator
+from dataclasses import dataclass
+import re
from typing import Any
+from urllib.parse import urlparse
import httpx
from django.conf import settings
@@ -9,8 +12,19 @@ class MoySkladError(RuntimeError):
pass
+@dataclass(frozen=True)
+class MoySkladRemoteImage:
+ external_id: str
+ filename: str
+ title: str
+ size: int
+ updated: str
+ download_href: str
+
+
class MoySkladClient:
DEFAULT_BASE_URL = "https://api.moysklad.ru/api/remap/1.2"
+ MAX_IMAGE_BYTES = 25 * 1024 * 1024
def __init__(self, *, auth_type: str, credentials: dict[str, str]):
self.base_url = (
@@ -20,13 +34,18 @@ class MoySkladClient:
+ "/"
)
headers = {
- "Accept": "application/json",
+ "Accept": "application/json;charset=utf-8",
"Accept-Encoding": "gzip",
"User-Agent": "EUSRR-Commerce/1.0",
}
auth = None
if auth_type == "token":
- token = credentials.get("token", "").strip()
+ token = re.sub(
+ r"^Bearer\s+",
+ "",
+ credentials.get("token", "").strip(),
+ flags=re.IGNORECASE,
+ ).strip()
if not token:
raise MoySkladError("Не указан токен МойСклад.")
headers["Authorization"] = f"Bearer {token}"
@@ -62,12 +81,17 @@ class MoySkladClient:
payload = response.json()
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
+ detail = self._error_detail(exc.response)
if status in {401, 403}:
message = "МойСклад отклонил реквизиты или у пользователя нет прав API."
+ elif status == 400:
+ message = "МойСклад не принял запрос. Проверьте access token и права пользователя."
elif status == 429:
message = "МойСклад временно ограничил частоту запросов."
else:
message = f"МойСклад вернул ошибку HTTP {status}."
+ if detail:
+ message = f"{message} Ответ сервиса: {detail}"
raise MoySkladError(message) from exc
except (httpx.HTTPError, ValueError) as exc:
raise MoySkladError(
@@ -77,15 +101,128 @@ class MoySkladClient:
raise MoySkladError("МойСклад вернул ответ неожиданного формата.")
return payload
+ def product_images(
+ self,
+ entity_type: str,
+ external_id: str,
+ ) -> list[MoySkladRemoteImage]:
+ if entity_type not in {"product", "variant", "bundle"}:
+ return []
+ payload = self._get(f"/entity/{entity_type}/{external_id}/images")
+ rows = payload.get("rows") or []
+ if not isinstance(rows, list):
+ raise MoySkladError("МойСклад вернул некорректный список изображений.")
+
+ return self.parse_product_images(rows)
+
+ @staticmethod
+ def parse_product_images(rows: list[dict]) -> list[MoySkladRemoteImage]:
+ images = []
+ for row in rows:
+ if not isinstance(row, dict):
+ continue
+ meta = row.get("meta") if isinstance(row.get("meta"), dict) else {}
+ href = str(meta.get("href") or "").rstrip("/")
+ download_href = str(meta.get("downloadHref") or "").strip()
+ image_id = href.rsplit("/", 1)[-1]
+ if not image_id or not download_href:
+ continue
+ images.append(
+ MoySkladRemoteImage(
+ external_id=image_id[:255],
+ filename=str(row.get("filename") or "image")[:255],
+ title=str(row.get("title") or "")[:255],
+ size=max(0, int(row.get("size") or 0)),
+ updated=str(row.get("updated") or ""),
+ download_href=download_href,
+ )
+ )
+ return images
+
+ def download_image(self, download_href: str) -> tuple[bytes, str]:
+ source = urlparse(download_href)
+ api = urlparse(self.base_url)
+ if source.scheme != "https" or source.netloc != api.netloc:
+ raise MoySkladError("МойСклад вернул недопустимую ссылку на изображение.")
+
+ try:
+ with self.client.stream(
+ "GET",
+ download_href,
+ follow_redirects=True,
+ ) as response:
+ response.raise_for_status()
+ declared_size = int(response.headers.get("Content-Length") or 0)
+ if declared_size > self.MAX_IMAGE_BYTES:
+ raise MoySkladError("Изображение МойСклад превышает лимит 25 МБ.")
+ chunks = []
+ received = 0
+ for chunk in response.iter_bytes():
+ received += len(chunk)
+ if received > self.MAX_IMAGE_BYTES:
+ raise MoySkladError("Изображение МойСклад превышает лимит 25 МБ.")
+ chunks.append(chunk)
+ content_type = response.headers.get("Content-Type", "").split(";", 1)[0]
+ except MoySkladError:
+ raise
+ except httpx.HTTPError as exc:
+ raise MoySkladError("Не удалось скачать изображение из МойСклад.") from exc
+
+ if not chunks:
+ raise MoySkladError("МойСклад вернул пустое изображение.")
+ if content_type and not (
+ content_type.startswith("image/")
+ or content_type == "application/octet-stream"
+ ):
+ raise MoySkladError("МойСклад вернул файл неподдерживаемого типа.")
+ return b"".join(chunks), content_type
+
+ @staticmethod
+ def _error_detail(response: httpx.Response) -> str:
+ try:
+ payload = response.json()
+ except ValueError:
+ return ""
+ candidates: list[Any] = []
+ if isinstance(payload, dict):
+ errors = payload.get("errors")
+ if isinstance(errors, list):
+ candidates.extend(errors)
+ candidates.append(payload)
+ for candidate in candidates:
+ if not isinstance(candidate, dict):
+ continue
+ for key in ("error", "message", "detail"):
+ value = candidate.get(key)
+ if isinstance(value, str) and value.strip():
+ return " ".join(value.split())[:500]
+ return ""
+
def employee_context(self) -> dict:
return self._get("/context/employee")
- def iter_assortment(self, *, limit: int = 1000) -> Iterator[dict]:
+ def sales_channel_names(self) -> dict[str, str]:
+ payload = self._get("/entity/saleschannel", params={"limit": 100})
+ rows = payload.get("rows") or []
+ if not isinstance(rows, list):
+ raise MoySkladError("МойСклад вернул некорректный список каналов продаж.")
+ return {
+ str(row.get("id")): str(row.get("name") or "")
+ for row in rows
+ if isinstance(row, dict) and row.get("id")
+ }
+
+ def iter_assortment(self, *, limit: int = 100) -> Iterator[dict]:
offset = 0
while True:
payload = self._get(
"/entity/assortment",
- params={"limit": limit, "offset": offset, "stockMode": "all"},
+ params={
+ "limit": min(limit, 100),
+ "offset": offset,
+ "stockMode": "all",
+ "expand": "images",
+ },
)
rows = payload.get("rows", [])
if not isinstance(rows, list):
@@ -93,9 +230,9 @@ class MoySkladClient:
for row in rows:
if isinstance(row, dict):
yield row
- if len(rows) < limit:
+ if len(rows) < min(limit, 100):
return
- offset += limit
+ offset += min(limit, 100)
def iter_documents(self, entity_type: str, *, limit: int = 100) -> Iterator[dict]:
if entity_type not in {"demand", "salesreturn"}:
@@ -107,7 +244,7 @@ class MoySkladClient:
params={
"limit": limit,
"offset": offset,
- "expand": "positions.assortment,state",
+ "expand": "positions.assortment,state,salesChannel,rate.currency",
},
)
rows = payload.get("rows", [])
@@ -119,3 +256,21 @@ class MoySkladClient:
if len(rows) < limit:
return
offset += limit
+
+ def iter_stock_by_store(self, *, limit: int = 1000) -> Iterator[dict]:
+ offset = 0
+ while True:
+ page_size = min(limit, 1000)
+ payload = self._get(
+ "/report/stock/bystore",
+ params={"limit": page_size, "offset": offset},
+ )
+ rows = payload.get("rows", [])
+ if not isinstance(rows, list):
+ raise MoySkladError("В отчёте остатков отсутствует список rows.")
+ for row in rows:
+ if isinstance(row, dict):
+ yield row
+ if len(rows) < page_size:
+ return
+ offset += page_size
diff --git a/backend/commerce/management/__init__.py b/backend/commerce/management/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/backend/commerce/management/__init__.py
@@ -0,0 +1 @@
+
diff --git a/backend/commerce/management/commands/__init__.py b/backend/commerce/management/commands/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/backend/commerce/management/commands/__init__.py
@@ -0,0 +1 @@
+
diff --git a/backend/commerce/management/commands/repair_moysklad_data.py b/backend/commerce/management/commands/repair_moysklad_data.py
new file mode 100644
index 00000000..aa31a1cf
--- /dev/null
+++ b/backend/commerce/management/commands/repair_moysklad_data.py
@@ -0,0 +1,158 @@
+from datetime import timedelta
+
+from django.core.management.base import BaseCommand, CommandError
+from django.db import transaction
+from django.utils import timezone
+
+from commerce.integrations.moysklad import MoySkladClient, MoySkladError
+from commerce.media import sync_moysklad_product_images
+from commerce.models import (
+ IntegrationConnection,
+ IntegrationSyncRun,
+ IntegrationStatus,
+ ProductBinding,
+ ProductImage,
+ Sale,
+ SourceRecord,
+ StockSnapshot,
+)
+from commerce.services.credentials import CredentialError, decrypt_credentials
+from commerce.services.moysklad_sync import (
+ _document_channel,
+ normalize_moysklad_stock_row,
+ reconcile_moysklad_variant_parents,
+)
+
+
+class Command(BaseCommand):
+ help = "Восстанавливает нормализованные данные МойСклад из SourceRecord и API."
+
+ def handle(self, *args, **options): # noqa: C901 - orchestration command
+ try:
+ connection = IntegrationConnection.objects.select_related("source").get(
+ provider="moysklad"
+ )
+ credentials = decrypt_credentials(connection)
+ except IntegrationConnection.DoesNotExist as exc:
+ raise CommandError("Подключение МойСклад не найдено.") from exc
+ except CredentialError as exc:
+ raise CommandError(str(exc)) from exc
+
+ source = connection.source
+ bindings = {
+ binding.external_id: binding
+ for binding in ProductBinding.objects.filter(source=source).select_related(
+ "product"
+ )
+ }
+ existing_images = set(
+ ProductImage.objects.filter(source=source).values_list(
+ "product_id", "external_id"
+ )
+ )
+ image_rows = []
+ for record in SourceRecord.objects.filter(
+ source=source,
+ entity_type="product",
+ ).only("external_id", "payload"):
+ binding = bindings.get(record.external_id)
+ if binding is None:
+ continue
+ images = (
+ record.payload.get("images")
+ if isinstance(record.payload.get("images"), dict)
+ else {}
+ )
+ remote_ids = []
+ for image in images.get("rows") or []:
+ if not isinstance(image, dict):
+ continue
+ href = str((image.get("meta") or {}).get("href") or "").rstrip("/")
+ image_id = href.rsplit("/", 1)[-1]
+ if image_id:
+ remote_ids.append(image_id)
+ if any(
+ (binding.product_id, image_id) not in existing_images
+ for image_id in remote_ids
+ ):
+ image_rows.append(record.payload)
+
+ repaired_images = 0
+ image_errors = 0
+ stock_snapshots = 0
+ updated_channels = 0
+ captured_at = timezone.now()
+ try:
+ with MoySkladClient(
+ auth_type=connection.auth_type,
+ credentials=credentials,
+ ) as client:
+ channel_names = client.sales_channel_names()
+ for record in SourceRecord.objects.filter(
+ source=source,
+ entity_type__in=["demand", "salesreturn"],
+ ).only("entity_type", "external_id", "payload"):
+ channel = _document_channel(record.payload, channel_names)
+ updated_channels += Sale.objects.filter(
+ source=source,
+ external_id=f"{record.entity_type}:{record.external_id}",
+ ).exclude(channel=channel).update(channel=channel)
+
+ for row in image_rows:
+ try:
+ repaired_images += sync_moysklad_product_images(
+ connection=connection,
+ row=row,
+ client=client,
+ )
+ except Exception: # noqa: BLE001 - continue repairing other images
+ image_errors += 1
+
+ for row in client.iter_stock_by_store():
+ stock_snapshots += normalize_moysklad_stock_row(
+ connection=connection,
+ row=row,
+ captured_at=captured_at,
+ )
+ except MoySkladError as exc:
+ raise CommandError(str(exc)) from exc
+
+ with transaction.atomic():
+ deleted_aggregate_stock, _ = StockSnapshot.objects.filter(
+ source=source,
+ warehouse_external_id="all",
+ ).delete()
+ reconciled_parents = reconcile_moysklad_variant_parents(source)
+ IntegrationSyncRun.objects.filter(
+ connection=connection,
+ status__in=["queued", "running"],
+ ).update(
+ status="error",
+ error="Предыдущая синхронизация была прервана; данные восстановлены.",
+ finished_at=timezone.now(),
+ )
+ connection.status = IntegrationStatus.READY
+ connection.last_error = ""
+ connection.next_sync_at = timezone.now() + timedelta(
+ minutes=connection.sync_interval_minutes
+ )
+ connection.save(
+ update_fields=[
+ "status",
+ "last_error",
+ "next_sync_at",
+ "updated_at",
+ ]
+ )
+
+ self.stdout.write(
+ self.style.SUCCESS(
+ "Восстановление завершено: "
+ f"каналы={updated_channels}, "
+ f"изображения={repaired_images}, "
+ f"ошибки изображений={image_errors}, "
+ f"остатки={stock_snapshots}, "
+ f"удалено агрегированных остатков={deleted_aggregate_stock}, "
+ f"родительские связи={reconciled_parents}."
+ )
+ )
diff --git a/backend/commerce/media.py b/backend/commerce/media.py
new file mode 100644
index 00000000..386b7784
--- /dev/null
+++ b/backend/commerce/media.py
@@ -0,0 +1,189 @@
+import hashlib
+import io
+from pathlib import Path
+import warnings
+
+from dateutil import parser as date_parser
+from django.core.files.base import ContentFile
+from django.db import transaction
+from django.utils import timezone
+from PIL import Image, ImageOps
+
+from commerce.integrations.moysklad import MoySkladClient
+from commerce.models import IntegrationConnection, ProductImage
+
+
+PRODUCT_THUMBNAIL_SIZE = (480, 480)
+MAX_PRODUCT_IMAGE_PIXELS = 40_000_000
+IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"}
+
+
+def _source_updated_at(value: str):
+ if not value:
+ return None
+ try:
+ parsed = date_parser.parse(value)
+ except (TypeError, ValueError, OverflowError):
+ return None
+ if timezone.is_naive(parsed):
+ parsed = timezone.make_aware(parsed)
+ return parsed
+
+
+def _validated_image_and_thumbnail(content: bytes) -> tuple[bytes, str]:
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", Image.DecompressionBombWarning)
+ with Image.open(io.BytesIO(content)) as probe:
+ if probe.width * probe.height > MAX_PRODUCT_IMAGE_PIXELS:
+ raise ValueError("Изображение превышает лимит 40 мегапикселей.")
+ image_format = (probe.format or "").upper()
+ probe.verify()
+ with Image.open(io.BytesIO(content)) as source:
+ source.seek(0)
+ image = ImageOps.exif_transpose(source)
+ if image.mode not in {"RGB", "RGBA"}:
+ image = image.convert("RGBA" if "transparency" in image.info else "RGB")
+ image.thumbnail(PRODUCT_THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
+ output = io.BytesIO()
+ image.save(output, format="WEBP", quality=84, method=5)
+ suffix = {
+ "JPEG": ".jpg",
+ "PNG": ".png",
+ "WEBP": ".webp",
+ "GIF": ".gif",
+ "BMP": ".bmp",
+ }.get(image_format, ".img")
+ return output.getvalue(), suffix
+
+
+def _storage_filename(
+ external_id: str,
+ source_name: str,
+ content: bytes,
+ detected_suffix: str,
+) -> str:
+ suffix = Path(source_name).suffix.lower()
+ if suffix not in IMAGE_SUFFIXES:
+ suffix = detected_suffix
+ digest = hashlib.sha256(content).hexdigest()[:16]
+ return f"{external_id}-{digest}{suffix}"
+
+
+def _delete_replaced_files(image, old_names):
+ for field_name, old_name in old_names.items():
+ field = getattr(image, field_name)
+ if old_name and old_name != field.name:
+ field.storage.delete(old_name)
+
+
+def sync_moysklad_product_images(
+ *,
+ connection: IntegrationConnection,
+ row: dict,
+ client: MoySkladClient,
+) -> int:
+ external_product_id = str(row.get("id") or "").strip()
+ if not external_product_id:
+ return 0
+ binding = connection.source.product_bindings.select_related("product").get(
+ external_id=external_product_id
+ )
+ product = binding.product
+ images_meta = row.get("images") if isinstance(row.get("images"), dict) else {}
+ collection_meta = (
+ images_meta.get("meta") if isinstance(images_meta.get("meta"), dict) else {}
+ )
+ image_count = max(0, int(collection_meta.get("size") or 0))
+ if image_count == 0:
+ product.images.filter(source=connection.source).delete()
+ return 0
+
+ entity_meta = row.get("meta") if isinstance(row.get("meta"), dict) else {}
+ entity_type = str(entity_meta.get("type") or "")
+ embedded_rows = images_meta.get("rows")
+ remote_images = (
+ client.parse_product_images(embedded_rows)
+ if isinstance(embedded_rows, list)
+ else client.product_images(entity_type, external_product_id)
+ )
+ retained_ids = []
+
+ for position, remote in enumerate(remote_images):
+ source_updated_at = _source_updated_at(remote.updated)
+ image = ProductImage.objects.filter(
+ product=product,
+ source=connection.source,
+ external_id=remote.external_id,
+ ).first()
+ unchanged = bool(
+ image
+ and image.source_size == remote.size
+ and image.source_updated_at == source_updated_at
+ and image.file
+ and image.file.storage.exists(image.file.name)
+ and image.thumbnail
+ and image.thumbnail.storage.exists(image.thumbnail.name)
+ )
+ if unchanged:
+ image.product = product
+ image.filename = remote.filename
+ image.title = remote.title
+ image.position = position
+ image.is_primary = position == 0
+ image.save(
+ update_fields=[
+ "product",
+ "filename",
+ "title",
+ "position",
+ "is_primary",
+ "updated_at",
+ ]
+ )
+ retained_ids.append(remote.external_id)
+ continue
+
+ content, _content_type = client.download_image(remote.download_href)
+ thumbnail, detected_suffix = _validated_image_and_thumbnail(content)
+ storage_name = _storage_filename(
+ remote.external_id,
+ remote.filename,
+ content,
+ detected_suffix,
+ )
+ if image is None:
+ image = ProductImage(
+ source=connection.source,
+ external_id=remote.external_id,
+ )
+ old_names = {
+ "file": image.file.name if image.file else "",
+ "thumbnail": image.thumbnail.name if image.thumbnail else "",
+ }
+ image.product = product
+ image.filename = remote.filename
+ image.title = remote.title
+ image.source_size = remote.size
+ image.source_updated_at = source_updated_at
+ image.position = position
+ image.is_primary = position == 0
+ image.file.save(storage_name, ContentFile(content), save=False)
+ image.thumbnail.save(
+ f"{remote.external_id}-{hashlib.sha256(content).hexdigest()[:16]}.webp",
+ ContentFile(thumbnail),
+ save=False,
+ )
+ with transaction.atomic():
+ image.save()
+ transaction.on_commit(
+ lambda current=image, previous=old_names: _delete_replaced_files(
+ current,
+ previous,
+ )
+ )
+ retained_ids.append(remote.external_id)
+
+ product.images.filter(source=connection.source).exclude(
+ external_id__in=retained_ids
+ ).delete()
+ return len(retained_ids)
diff --git a/backend/commerce/migrations/0004_productimage_integrationsyncrun_progress.py b/backend/commerce/migrations/0004_productimage_integrationsyncrun_progress.py
new file mode 100644
index 00000000..95b6cc73
--- /dev/null
+++ b/backend/commerce/migrations/0004_productimage_integrationsyncrun_progress.py
@@ -0,0 +1,102 @@
+# Generated by Django 5.2.4 on 2026-08-17
+
+import commerce.models.product
+import django.db.models.deletion
+import uuid
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("commerce", "0003_integrationconnection_auto_sync_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="integrationsyncrun",
+ name="images",
+ field=models.PositiveIntegerField(default=0, verbose_name="Изображений"),
+ ),
+ migrations.AddField(
+ model_name="integrationsyncrun",
+ name="sales",
+ field=models.PositiveIntegerField(
+ default=0,
+ verbose_name="Продаж и возвратов",
+ ),
+ ),
+ migrations.CreateModel(
+ name="ProductImage",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("external_id", models.CharField(max_length=255, verbose_name="Внешний ID")),
+ (
+ "file",
+ models.ImageField(
+ upload_to=commerce.models.product.product_image_upload_to,
+ verbose_name="Оригинал",
+ ),
+ ),
+ (
+ "thumbnail",
+ models.ImageField(
+ blank=True,
+ upload_to=commerce.models.product.product_thumbnail_upload_to,
+ verbose_name="Миниатюра",
+ ),
+ ),
+ ("filename", models.CharField(blank=True, max_length=255, verbose_name="Имя файла")),
+ ("title", models.CharField(blank=True, max_length=255, verbose_name="Название")),
+ ("source_size", models.PositiveBigIntegerField(default=0, verbose_name="Размер в источнике")),
+ ("source_updated_at", models.DateTimeField(blank=True, null=True, verbose_name="Изменено в источнике")),
+ ("position", models.PositiveIntegerField(default=0, verbose_name="Позиция")),
+ ("is_primary", models.BooleanField(default=False, verbose_name="Основное")),
+ ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Создано")),
+ ("updated_at", models.DateTimeField(auto_now=True, verbose_name="Обновлено")),
+ (
+ "product",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="images",
+ to="commerce.product",
+ verbose_name="Товар",
+ ),
+ ),
+ (
+ "source",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="product_images",
+ to="commerce.sourcesystem",
+ verbose_name="Источник",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Изображение товара",
+ "verbose_name_plural": "Изображения товаров",
+ "ordering": ["position", "created_at"],
+ "indexes": [
+ models.Index(
+ fields=["product", "is_primary", "position"],
+ name="commerce_product_image_order",
+ )
+ ],
+ "constraints": [
+ models.UniqueConstraint(
+ fields=("source", "external_id"),
+ name="commerce_product_image_source_external",
+ )
+ ],
+ },
+ ),
+ ]
diff --git a/backend/commerce/migrations/0005_productimage_paths_and_image_errors.py b/backend/commerce/migrations/0005_productimage_paths_and_image_errors.py
new file mode 100644
index 00000000..9873fc8a
--- /dev/null
+++ b/backend/commerce/migrations/0005_productimage_paths_and_image_errors.py
@@ -0,0 +1,41 @@
+# Generated by Django 5.2.4 on 2026-08-17
+
+import commerce.models.product
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("commerce", "0004_productimage_integrationsyncrun_progress"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="integrationsyncrun",
+ name="image_errors",
+ field=models.PositiveIntegerField(
+ default=0,
+ verbose_name="Ошибок изображений",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="productimage",
+ name="file",
+ field=models.ImageField(
+ max_length=500,
+ upload_to=commerce.models.product.product_image_upload_to,
+ verbose_name="Оригинал",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="productimage",
+ name="thumbnail",
+ field=models.ImageField(
+ blank=True,
+ max_length=500,
+ upload_to=commerce.models.product.product_thumbnail_upload_to,
+ verbose_name="Миниатюра",
+ ),
+ ),
+ ]
diff --git a/backend/commerce/migrations/0006_repair_moysklad_catalog.py b/backend/commerce/migrations/0006_repair_moysklad_catalog.py
new file mode 100644
index 00000000..99fd64c9
--- /dev/null
+++ b/backend/commerce/migrations/0006_repair_moysklad_catalog.py
@@ -0,0 +1,326 @@
+# Generated by Django 5.2.4 on 2026-08-18
+
+import re
+from collections import defaultdict
+from decimal import Decimal, InvalidOperation
+
+from django.db import migrations, models
+
+
+def _external_id(value):
+ if not isinstance(value, dict):
+ return ""
+ direct = str(value.get("id") or "").strip()
+ if direct:
+ return direct
+ meta = value.get("meta") if isinstance(value.get("meta"), dict) else {}
+ href = str(meta.get("href") or "").rstrip("/")
+ return href.rsplit("/", 1)[-1]
+
+
+def _decimal(value):
+ try:
+ return Decimal(str(value or 0))
+ except (InvalidOperation, TypeError, ValueError):
+ return Decimal("0")
+
+
+def _money(value):
+ return (_decimal(value) / Decimal("100")).quantize(Decimal("0.01"))
+
+
+def _barcode(row):
+ for item in row.get("barcodes") or []:
+ if isinstance(item, str) and item.strip():
+ return item.strip()
+ if isinstance(item, dict):
+ for value in item.values():
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return ""
+
+
+def _kind(row):
+ source_type = str((row.get("meta") or {}).get("type") or "")
+ return {"service": "service", "bundle": "bundle"}.get(source_type, "product")
+
+
+def _unique_sku(row, external_id, used):
+ raw = str(row.get("article") or row.get("code") or "").strip()
+ raw = re.sub(r"\s+", "-", raw or f"moysklad-{external_id}")[:128]
+ candidate = raw
+ if candidate.casefold() not in used:
+ used.add(candidate.casefold())
+ return candidate
+ suffix = external_id.replace("-", "")[:10]
+ base = raw[: max(1, 117 - len(suffix))]
+ candidate = f"{base}-ms-{suffix}"[:128]
+ sequence = 2
+ while candidate.casefold() in used:
+ tail = f"-{sequence}"
+ candidate = f"{base[: 128 - len(tail)]}{tail}"
+ sequence += 1
+ used.add(candidate.casefold())
+ return candidate
+
+
+def _metadata(row):
+ def price_value(value):
+ if not isinstance(value, dict) or "value" not in value:
+ return None
+ return str(_money(value.get("value")))
+
+ sale_prices = []
+ for price in row.get("salePrices") or []:
+ if not isinstance(price, dict):
+ continue
+ price_type = price.get("priceType") if isinstance(price.get("priceType"), dict) else {}
+ sale_prices.append(
+ {
+ "name": str(price_type.get("name") or ""),
+ "external_id": _external_id(price_type),
+ "value": str(_money(price.get("value"))),
+ }
+ )
+ return {
+ "provider": "moysklad",
+ "entity_type": str((row.get("meta") or {}).get("type") or ""),
+ "external_code": str(row.get("externalCode") or ""),
+ "parent_external_id": _external_id(row.get("product")),
+ "purchase_price": price_value(row.get("buyPrice")),
+ "minimum_price": price_value(row.get("minPrice")),
+ "sale_prices": sale_prices,
+ "uom": {
+ "external_id": _external_id(row.get("uom")),
+ "name": str((row.get("uom") or {}).get("name") or "")
+ if isinstance(row.get("uom"), dict)
+ else "",
+ },
+ "folder": {
+ "external_id": _external_id(row.get("productFolder")),
+ "name": str(row.get("pathName") or ""),
+ },
+ "weight": row.get("weight"),
+ "volume": row.get("volume"),
+ "vat": row.get("effectiveVat", row.get("vat")),
+ "attributes": row.get("attributes") or [],
+ "characteristics": row.get("characteristics") or [],
+ "source_updated_at": str(row.get("updated") or ""),
+ }
+
+
+def _update_product(product, row):
+ external_id = str(row.get("id") or "")
+ external_sku = str(row.get("article") or row.get("code") or "").strip()
+ product.name = str(row.get("name") or external_sku or external_id)[:255]
+ product.barcode = _barcode(row)[:64]
+ product.description = str(row.get("description") or "")
+ product.kind = _kind(row)
+ product.is_active = not bool(row.get("archived"))
+ product.save(update_fields=["name", "barcode", "description", "kind", "is_active", "updated_at"])
+
+
+def repair_moysklad_catalog(apps, schema_editor): # noqa: C901 - one-off repair
+ SourceSystem = apps.get_model("commerce", "SourceSystem")
+ SourceRecord = apps.get_model("commerce", "SourceRecord")
+ Product = apps.get_model("commerce", "Product")
+ ProductBinding = apps.get_model("commerce", "ProductBinding")
+ ProductImage = apps.get_model("commerce", "ProductImage")
+ Sale = apps.get_model("commerce", "Sale")
+ SaleLine = apps.get_model("commerce", "SaleLine")
+ StockSnapshot = apps.get_model("commerce", "StockSnapshot")
+
+ source = SourceSystem.objects.filter(code="moysklad").first()
+ if source is None:
+ return
+
+ product_records = {
+ record.external_id: record.payload
+ for record in SourceRecord.objects.filter(source=source, entity_type="product")
+ }
+ used_skus = {value.casefold() for value in Product.objects.values_list("sku", flat=True)}
+ grouped = defaultdict(list)
+ for binding in ProductBinding.objects.filter(source=source).select_related("product"):
+ grouped[binding.product_id].append(binding)
+
+ type_priority = {"product": 0, "bundle": 1, "service": 2, "variant": 3}
+ for bindings in grouped.values():
+ bindings.sort(
+ key=lambda binding: (
+ type_priority.get(
+ str((product_records.get(binding.external_id, {}).get("meta") or {}).get("type") or ""),
+ 9,
+ ),
+ str(binding.created_at),
+ )
+ )
+ keeper = bindings[0]
+ keeper_raw = product_records.get(keeper.external_id, {})
+ keeper.metadata = _metadata(keeper_raw)
+ keeper.external_sku = str(keeper_raw.get("article") or keeper_raw.get("code") or "")[:128]
+ keeper.save(update_fields=["metadata", "external_sku", "updated_at"])
+ if keeper_raw:
+ _update_product(keeper.product, keeper_raw)
+
+ for binding in bindings[1:]:
+ raw = product_records.get(binding.external_id, {})
+ product = Product.objects.create(
+ sku=_unique_sku(raw, binding.external_id, used_skus),
+ name=str(raw.get("name") or binding.external_sku or binding.external_id)[:255],
+ barcode=_barcode(raw)[:64],
+ description=str(raw.get("description") or ""),
+ kind=_kind(raw),
+ is_active=not bool(raw.get("archived")),
+ )
+ binding.product = product
+ binding.external_sku = str(raw.get("article") or raw.get("code") or "")[:128]
+ binding.metadata = _metadata(raw)
+ binding.save(update_fields=["product", "external_sku", "metadata", "updated_at"])
+ SaleLine.objects.filter(
+ sale__source=source,
+ external_product_id=binding.external_id,
+ ).update(product=product)
+ StockSnapshot.objects.filter(
+ source=source,
+ metadata__external_product_id=binding.external_id,
+ ).update(product=product)
+
+ bindings_by_external = {
+ binding.external_id: binding
+ for binding in ProductBinding.objects.filter(source=source).select_related("product")
+ }
+ for binding in bindings_by_external.values():
+ raw = product_records.get(binding.external_id, {})
+ metadata = _metadata(raw)
+ if binding.metadata != metadata:
+ binding.metadata = metadata
+ binding.save(update_fields=["metadata", "updated_at"])
+ parent_binding = bindings_by_external.get(str(metadata.get("parent_external_id") or ""))
+ if parent_binding and parent_binding.product_id != binding.product_id:
+ Product.objects.filter(pk=binding.product_id).update(parent_id=parent_binding.product_id)
+ SaleLine.objects.filter(
+ sale__source=source,
+ external_product_id=binding.external_id,
+ ).update(product_id=binding.product_id)
+
+ desired_images = defaultdict(set)
+ for external_id, raw in product_records.items():
+ binding = bindings_by_external.get(external_id)
+ if binding is None:
+ continue
+ images = raw.get("images") if isinstance(raw.get("images"), dict) else {}
+ for image_row in images.get("rows") or []:
+ image_id = _external_id(image_row)
+ if image_id:
+ desired_images[image_id].add(binding.product_id)
+ for image_id, product_ids in desired_images.items():
+ originals = list(ProductImage.objects.filter(source=source, external_id=image_id))
+ if not originals:
+ continue
+ original = originals[0]
+ for product_id in product_ids:
+ if ProductImage.objects.filter(
+ product_id=product_id,
+ source=source,
+ external_id=image_id,
+ ).exists():
+ continue
+ ProductImage.objects.create(
+ product_id=product_id,
+ source=source,
+ external_id=image_id,
+ file=original.file,
+ thumbnail=original.thumbnail,
+ filename=original.filename,
+ title=original.title,
+ source_size=original.source_size,
+ source_updated_at=original.source_updated_at,
+ position=original.position,
+ is_primary=original.is_primary,
+ )
+ ProductImage.objects.filter(source=source, external_id=image_id).exclude(
+ product_id__in=product_ids
+ ).delete()
+
+ sales = {
+ sale.external_id: sale
+ for sale in Sale.objects.filter(source=source)
+ }
+ for record in SourceRecord.objects.filter(
+ source=source,
+ entity_type__in=["demand", "salesreturn"],
+ ).iterator(chunk_size=250):
+ sale = sales.get(f"{record.entity_type}:{record.external_id}")
+ if sale is None:
+ continue
+ sales_channel = record.payload.get("salesChannel")
+ sale.channel = (
+ str(sales_channel.get("name") or "Не указан")[:64]
+ if isinstance(sales_channel, dict)
+ else "Не указан"
+ )
+ sale.save(update_fields=["channel", "updated_at"])
+ positions = record.payload.get("positions") or []
+ if isinstance(positions, dict):
+ positions = positions.get("rows") or []
+ for index, position in enumerate(positions, start=1):
+ if not isinstance(position, dict):
+ continue
+ position_id = str(position.get("id") or index)
+ assortment = position.get("assortment") if isinstance(position.get("assortment"), dict) else {}
+ product_external_id = _external_id(assortment)
+ binding = bindings_by_external.get(product_external_id)
+ quantity = _decimal(position.get("quantity"))
+ position_cost = _money(position.get("cost"))
+ buy_price = assortment.get("buyPrice") if isinstance(assortment.get("buyPrice"), dict) else {}
+ unit_cost = position_cost if position_cost > 0 else _money(buy_price.get("value"))
+ line_updates = {
+ "cost_amount": (quantity * unit_cost).quantize(Decimal("0.01")),
+ "metadata": {
+ "provider": "moysklad",
+ "cost_source": (
+ "position.cost"
+ if position_cost > 0
+ else ("assortment.buyPrice" if unit_cost > 0 else "unavailable")
+ ),
+ "vat": position.get("vat"),
+ "vat_enabled": bool(position.get("vatEnabled")),
+ "overhead": str(_money(position.get("overhead"))),
+ },
+ }
+ if binding:
+ line_updates["product_id"] = binding.product_id
+ SaleLine.objects.filter(sale=sale, external_id=position_id).update(
+ **line_updates
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("commerce", "0005_productimage_paths_and_image_errors"),
+ ]
+
+ operations = [
+ migrations.RemoveConstraint(
+ model_name="productimage",
+ name="commerce_product_image_source_external",
+ ),
+ migrations.AddField(
+ model_name="productbinding",
+ name="metadata",
+ field=models.JSONField(
+ blank=True,
+ default=dict,
+ verbose_name="Нормализованные данные источника",
+ ),
+ ),
+ migrations.RunPython(repair_moysklad_catalog, migrations.RunPython.noop),
+ migrations.AddConstraint(
+ model_name="productimage",
+ constraint=models.UniqueConstraint(
+ fields=("product", "source", "external_id"),
+ name="commerce_product_image_identity",
+ ),
+ ),
+ ]
diff --git a/backend/commerce/migrations/0007_recover_sale_line_matches.py b/backend/commerce/migrations/0007_recover_sale_line_matches.py
new file mode 100644
index 00000000..0fff629c
--- /dev/null
+++ b/backend/commerce/migrations/0007_recover_sale_line_matches.py
@@ -0,0 +1,54 @@
+from django.db import migrations
+
+
+def recover_sale_line_matches(apps, schema_editor):
+ SourceSystem = apps.get_model("commerce", "SourceSystem")
+ Product = apps.get_model("commerce", "Product")
+ ProductBinding = apps.get_model("commerce", "ProductBinding")
+ SaleLine = apps.get_model("commerce", "SaleLine")
+
+ source = SourceSystem.objects.filter(code="moysklad").first()
+ if source is None:
+ return
+
+ lines = SaleLine.objects.filter(
+ sale__source=source,
+ product__isnull=True,
+ ).only("id", "external_product_id", "external_sku")
+ for line in lines.iterator(chunk_size=250):
+ product_id = None
+ if line.external_product_id:
+ product_id = ProductBinding.objects.filter(
+ source=source,
+ external_id=line.external_product_id,
+ ).values_list("product_id", flat=True).first()
+ if product_id is None and line.external_sku:
+ binding_matches = list(
+ ProductBinding.objects.filter(
+ source=source,
+ external_sku__iexact=line.external_sku,
+ ).values_list("product_id", flat=True)[:2]
+ )
+ if len(binding_matches) == 1:
+ product_id = binding_matches[0]
+ else:
+ product_matches = list(
+ Product.objects.filter(sku__iexact=line.external_sku).values_list(
+ "id", flat=True
+ )[:2]
+ )
+ if len(product_matches) == 1:
+ product_id = product_matches[0]
+ if product_id is not None:
+ SaleLine.objects.filter(pk=line.pk).update(product_id=product_id)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("commerce", "0006_repair_moysklad_catalog"),
+ ]
+
+ operations = [
+ migrations.RunPython(recover_sale_line_matches, migrations.RunPython.noop),
+ ]
diff --git a/backend/commerce/migrations/0008_create_historical_products.py b/backend/commerce/migrations/0008_create_historical_products.py
new file mode 100644
index 00000000..458861d6
--- /dev/null
+++ b/backend/commerce/migrations/0008_create_historical_products.py
@@ -0,0 +1,86 @@
+import re
+
+from django.db import migrations
+
+
+def _unique_sku(Product, external_sku, external_id):
+ raw = re.sub(
+ r"\s+",
+ "-",
+ str(external_sku or f"moysklad-{external_id}").strip(),
+ )[:128]
+ if not Product.objects.filter(sku__iexact=raw).exists():
+ return raw
+ suffix = external_id.replace("-", "")[:10]
+ base = raw[: max(1, 117 - len(suffix))]
+ candidate = f"{base}-ms-{suffix}"[:128]
+ sequence = 2
+ while Product.objects.filter(sku__iexact=candidate).exists():
+ tail = f"-{sequence}"
+ candidate = f"{base[: 128 - len(tail)]}{tail}"
+ sequence += 1
+ return candidate
+
+
+def create_historical_products(apps, schema_editor):
+ SourceSystem = apps.get_model("commerce", "SourceSystem")
+ Product = apps.get_model("commerce", "Product")
+ ProductBinding = apps.get_model("commerce", "ProductBinding")
+ SaleLine = apps.get_model("commerce", "SaleLine")
+
+ source = SourceSystem.objects.filter(code="moysklad").first()
+ if source is None:
+ return
+
+ missing_external_ids = list(
+ SaleLine.objects.filter(
+ sale__source=source,
+ product__isnull=True,
+ )
+ .exclude(external_product_id="")
+ .values_list("external_product_id", flat=True)
+ .distinct()
+ )
+ for external_id in missing_external_ids:
+ binding = ProductBinding.objects.filter(
+ source=source,
+ external_id=external_id,
+ ).first()
+ if binding is None:
+ sample = SaleLine.objects.filter(
+ sale__source=source,
+ external_product_id=external_id,
+ ).first()
+ product = Product.objects.create(
+ sku=_unique_sku(Product, sample.external_sku, external_id),
+ name=str(sample.name or sample.external_sku or external_id)[:255],
+ kind="product",
+ is_active=False,
+ )
+ binding = ProductBinding.objects.create(
+ product=product,
+ source=source,
+ external_id=external_id,
+ external_sku=str(sample.external_sku or "")[:128],
+ metadata={
+ "provider": "moysklad",
+ "historical_from_document": True,
+ },
+ is_primary=True,
+ )
+ SaleLine.objects.filter(
+ sale__source=source,
+ external_product_id=external_id,
+ product__isnull=True,
+ ).update(product_id=binding.product_id)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("commerce", "0007_recover_sale_line_matches"),
+ ]
+
+ operations = [
+ migrations.RunPython(create_historical_products, migrations.RunPython.noop),
+ ]
diff --git a/backend/commerce/models/__init__.py b/backend/commerce/models/__init__.py
index 026c2fe0..d9b804f5 100644
--- a/backend/commerce/models/__init__.py
+++ b/backend/commerce/models/__init__.py
@@ -1,5 +1,5 @@
from .costs import Cost, CostType
-from .product import Product, ProductKind
+from .product import Product, ProductImage, ProductKind
from .sales import Sale, SaleEventType, SaleLine
from .source import (
IntegrationConnection,
@@ -21,6 +21,7 @@ __all__ = [
"IntegrationSyncRun",
"Product",
"ProductBinding",
+ "ProductImage",
"ProductKind",
"Sale",
"SaleEventType",
diff --git a/backend/commerce/models/product.py b/backend/commerce/models/product.py
index a48a4865..df58a213 100644
--- a/backend/commerce/models/product.py
+++ b/backend/commerce/models/product.py
@@ -1,10 +1,19 @@
import uuid
+from pathlib import Path
from django.db import models
from django.db.models import Q
from django.db.models.functions import Lower
+def product_image_upload_to(instance, filename):
+ return f"commerce/products/{instance.product_id}/original/{Path(filename).name}"
+
+
+def product_thumbnail_upload_to(instance, filename):
+ return f"commerce/products/{instance.product_id}/thumbnails/{Path(filename).name}"
+
+
class ProductKind(models.TextChoices):
PRODUCT = "product", "Товар"
SERVICE = "service", "Услуга"
@@ -58,3 +67,65 @@ class Product(models.Model):
def __str__(self):
return f"{self.sku} — {self.name}"
+
+
+class ProductImage(models.Model):
+ """Locally stored image imported from an external product source."""
+
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ product = models.ForeignKey(
+ Product,
+ on_delete=models.CASCADE,
+ related_name="images",
+ verbose_name="Товар",
+ )
+ source = models.ForeignKey(
+ "commerce.SourceSystem",
+ on_delete=models.CASCADE,
+ related_name="product_images",
+ verbose_name="Источник",
+ )
+ external_id = models.CharField("Внешний ID", max_length=255)
+ file = models.ImageField(
+ "Оригинал",
+ upload_to=product_image_upload_to,
+ max_length=500,
+ )
+ thumbnail = models.ImageField(
+ "Миниатюра",
+ upload_to=product_thumbnail_upload_to,
+ max_length=500,
+ blank=True,
+ )
+ filename = models.CharField("Имя файла", max_length=255, blank=True)
+ title = models.CharField("Название", max_length=255, blank=True)
+ source_size = models.PositiveBigIntegerField("Размер в источнике", default=0)
+ source_updated_at = models.DateTimeField(
+ "Изменено в источнике",
+ null=True,
+ blank=True,
+ )
+ position = models.PositiveIntegerField("Позиция", default=0)
+ is_primary = models.BooleanField("Основное", default=False)
+ created_at = models.DateTimeField("Создано", auto_now_add=True)
+ updated_at = models.DateTimeField("Обновлено", auto_now=True)
+
+ class Meta:
+ verbose_name = "Изображение товара"
+ verbose_name_plural = "Изображения товаров"
+ ordering = ["position", "created_at"]
+ constraints = [
+ models.UniqueConstraint(
+ fields=["product", "source", "external_id"],
+ name="commerce_product_image_identity",
+ )
+ ]
+ indexes = [
+ models.Index(
+ fields=["product", "is_primary", "position"],
+ name="commerce_product_image_order",
+ )
+ ]
+
+ def __str__(self):
+ return self.title or self.filename or self.external_id
diff --git a/backend/commerce/models/source.py b/backend/commerce/models/source.py
index 00df2c71..357393bf 100644
--- a/backend/commerce/models/source.py
+++ b/backend/commerce/models/source.py
@@ -84,6 +84,11 @@ class ProductBinding(models.Model):
external_sku = models.CharField(
"Артикул в источнике", max_length=128, blank=True, db_index=True
)
+ metadata = models.JSONField(
+ "Нормализованные данные источника",
+ default=dict,
+ blank=True,
+ )
is_primary = models.BooleanField("Основная привязка", default=False)
created_at = models.DateTimeField("Создано", auto_now_add=True)
updated_at = models.DateTimeField("Обновлено", auto_now=True)
@@ -95,7 +100,7 @@ class ProductBinding(models.Model):
models.UniqueConstraint(
fields=["source", "external_id"],
name="commerce_binding_source_external",
- )
+ ),
]
indexes = [
models.Index(
@@ -214,6 +219,9 @@ class IntegrationSyncRun(models.Model):
created_products = models.PositiveIntegerField("Создано товаров", default=0)
matched_products = models.PositiveIntegerField("Сопоставлено", default=0)
stock_snapshots = models.PositiveIntegerField("Снимков остатков", default=0)
+ images = models.PositiveIntegerField("Изображений", default=0)
+ image_errors = models.PositiveIntegerField("Ошибок изображений", default=0)
+ sales = models.PositiveIntegerField("Продаж и возвратов", default=0)
error = models.TextField("Ошибка", blank=True)
started_at = models.DateTimeField("Начато", null=True, blank=True)
finished_at = models.DateTimeField("Завершено", null=True, blank=True)
diff --git a/backend/commerce/realtime.py b/backend/commerce/realtime.py
new file mode 100644
index 00000000..b5fac57f
--- /dev/null
+++ b/backend/commerce/realtime.py
@@ -0,0 +1,73 @@
+from asgiref.sync import async_to_sync
+from channels.layers import get_channel_layer
+
+from commerce.models import IntegrationConnection, IntegrationSyncRun
+
+
+COMMERCE_CONNECTIONS_GROUP = "commerce_connections"
+
+
+def _iso(value):
+ return value.isoformat() if value else None
+
+
+def _run_payload(run: IntegrationSyncRun | None):
+ if run is None:
+ return None
+ return {
+ "id": str(run.pk),
+ "connection": str(run.connection_id),
+ "status": run.status,
+ "task_id": run.task_id,
+ "processed": run.processed,
+ "created_products": run.created_products,
+ "matched_products": run.matched_products,
+ "stock_snapshots": run.stock_snapshots,
+ "images": run.images,
+ "image_errors": run.image_errors,
+ "sales": run.sales,
+ "error": run.error,
+ "started_at": _iso(run.started_at),
+ "finished_at": _iso(run.finished_at),
+ "created_at": _iso(run.created_at),
+ }
+
+
+def build_connection_update(
+ connection: IntegrationConnection,
+ *,
+ run: IntegrationSyncRun | None = None,
+):
+ return {
+ "id": str(connection.pk),
+ "status": connection.status,
+ "account_name": connection.account_name,
+ "last_error": connection.last_error,
+ "last_tested_at": _iso(connection.last_tested_at),
+ "last_sync_started_at": _iso(connection.last_sync_started_at),
+ "last_sync_finished_at": _iso(connection.last_sync_finished_at),
+ "last_stats": connection.last_stats,
+ "task_id": connection.task_id,
+ "auto_sync": connection.auto_sync,
+ "sync_interval_minutes": connection.sync_interval_minutes,
+ "next_sync_at": _iso(connection.next_sync_at),
+ "updated_at": _iso(connection.updated_at),
+ "recent_run": _run_payload(run),
+ }
+
+
+def send_connection_update(
+ connection: IntegrationConnection,
+ *,
+ run: IntegrationSyncRun | None = None,
+):
+ channel_layer = get_channel_layer()
+ if channel_layer is None:
+ return
+ async_to_sync(channel_layer.group_send)(
+ COMMERCE_CONNECTIONS_GROUP,
+ {
+ "type": "commerce_connection_updated",
+ "connection": build_connection_update(connection, run=run),
+ },
+ )
diff --git a/backend/commerce/services/metrics.py b/backend/commerce/services/metrics.py
index 1a97b0ec..07117aea 100644
--- a/backend/commerce/services/metrics.py
+++ b/backend/commerce/services/metrics.py
@@ -5,6 +5,7 @@ from decimal import Decimal
from django.db.models import (
Case,
CharField,
+ Count,
DecimalField,
ExpressionWrapper,
F,
@@ -62,6 +63,13 @@ def _sale_metric_rows(
gross=Coalesce(Sum(signed_gross), ZERO_MONEY),
discounts=Coalesce(Sum(signed_discount), ZERO_MONEY),
cost_of_goods=Coalesce(Sum(signed_cost), ZERO_MONEY),
+ missing_cost_lines=Count(
+ "id",
+ filter=Q(
+ sale__source__code="moysklad",
+ metadata__cost_source="unavailable",
+ ),
+ ),
)
.order_by(*group_fields)
)
@@ -161,6 +169,8 @@ def _channel_metrics(
"additional_costs": additional_costs,
"contribution_margin": net_revenue - cost_of_goods - additional_costs,
"cost_breakdown": expense_breakdown,
+ "missing_cost_lines": sale.get("missing_cost_lines", 0),
+ "cost_complete": sale.get("missing_cost_lines", 0) == 0,
}
)
return result
@@ -226,6 +236,8 @@ def get_product_metrics(
"additional_costs": additional_costs,
"contribution_margin": net_revenue - cost_of_goods - additional_costs,
"cost_breakdown": expense_breakdown,
+ "missing_cost_lines": sale.get("missing_cost_lines", 0),
+ "cost_complete": sale.get("missing_cost_lines", 0) == 0,
}
)
diff --git a/backend/commerce/services/moysklad_sync.py b/backend/commerce/services/moysklad_sync.py
index ba1b9769..8e893b9b 100644
--- a/backend/commerce/services/moysklad_sync.py
+++ b/backend/commerce/services/moysklad_sync.py
@@ -1,14 +1,17 @@
import re
+import logging
from dataclasses import asdict, dataclass
from datetime import datetime
from decimal import Decimal, InvalidOperation
-from typing import Any
+from typing import Any, Callable
+from urllib.parse import urlparse
from dateutil import parser as date_parser
from django.db import transaction
from django.utils import timezone
from commerce.integrations.moysklad import MoySkladClient
+from commerce.media import sync_moysklad_product_images
from commerce.models import (
IntegrationConnection,
Product,
@@ -24,12 +27,17 @@ from commerce.services.ingestion import ingest_source_record
from commerce.services.matching import match_product
+logger = logging.getLogger(__name__)
+
+
@dataclass
class MoySkladSyncStats:
processed: int = 0
created_products: int = 0
matched_products: int = 0
stock_snapshots: int = 0
+ images: int = 0
+ image_errors: int = 0
sales: int = 0
sale_lines: int = 0
@@ -42,7 +50,7 @@ def _external_id(row: dict[str, Any]) -> str:
if value:
return value
href = str(row.get("meta", {}).get("href") or "").rstrip("/")
- return href.rsplit("/", 1)[-1]
+ return urlparse(href).path.rstrip("/").rsplit("/", 1)[-1]
def _barcode(row: dict[str, Any]) -> str:
@@ -89,6 +97,98 @@ def _product_kind(row: dict[str, Any]) -> str:
return ProductKind.PRODUCT
+def _reference_external_id(value: Any) -> str:
+ if not isinstance(value, dict):
+ return ""
+ direct = str(value.get("id") or "").strip()
+ if direct:
+ return direct
+ meta = value.get("meta") if isinstance(value.get("meta"), dict) else {}
+ href = str(meta.get("href") or "").rstrip("/")
+ return urlparse(href).path.rstrip("/").rsplit("/", 1)[-1]
+
+
+def _reference_name(value: Any) -> str:
+ return str(value.get("name") or "") if isinstance(value, dict) else ""
+
+
+def _price_value(value: Any) -> str | None:
+ if not isinstance(value, dict) or "value" not in value:
+ return None
+ return str(_money(value.get("value")))
+
+
+def _binding_metadata(row: dict[str, Any]) -> dict[str, Any]:
+ sale_prices = []
+ for price in row.get("salePrices") or []:
+ if not isinstance(price, dict):
+ continue
+ price_type = (
+ price.get("priceType") if isinstance(price.get("priceType"), dict) else {}
+ )
+ sale_prices.append(
+ {
+ "name": str(price_type.get("name") or ""),
+ "external_id": _reference_external_id(price_type),
+ "value": str(_money(price.get("value"))),
+ }
+ )
+ attributes = []
+ for attribute in row.get("attributes") or []:
+ if isinstance(attribute, dict):
+ attributes.append(
+ {
+ "external_id": str(attribute.get("id") or ""),
+ "name": str(attribute.get("name") or ""),
+ "type": str(attribute.get("type") or ""),
+ "value": attribute.get("value"),
+ }
+ )
+ return {
+ "provider": "moysklad",
+ "entity_type": str((row.get("meta") or {}).get("type") or ""),
+ "external_code": str(row.get("externalCode") or ""),
+ "parent_external_id": _reference_external_id(row.get("product")),
+ "purchase_price": _price_value(row.get("buyPrice")),
+ "minimum_price": _price_value(row.get("minPrice")),
+ "sale_prices": sale_prices,
+ "uom": {
+ "external_id": _reference_external_id(row.get("uom")),
+ "name": _reference_name(row.get("uom")),
+ },
+ "folder": {
+ "external_id": _reference_external_id(row.get("productFolder")),
+ "name": str(row.get("pathName") or ""),
+ },
+ "weight": row.get("weight"),
+ "volume": row.get("volume"),
+ "vat": row.get("effectiveVat", row.get("vat")),
+ "attributes": attributes,
+ "characteristics": row.get("characteristics") or [],
+ "source_updated_at": str(row.get("updated") or ""),
+ }
+
+
+def _update_product_from_source(product: Product, row: dict[str, Any]) -> None:
+ external_id = _external_id(row)
+ external_sku = str(row.get("article") or row.get("code") or "").strip()
+ product.name = str(row.get("name") or external_sku or external_id)[:255]
+ product.barcode = _barcode(row)[:64]
+ product.description = str(row.get("description") or "")
+ product.kind = _product_kind(row)
+ product.is_active = not bool(row.get("archived"))
+ product.save(
+ update_fields=[
+ "name",
+ "barcode",
+ "description",
+ "kind",
+ "is_active",
+ "updated_at",
+ ]
+ )
+
+
def _candidate_sku(row: dict[str, Any], external_id: str) -> str:
raw = str(row.get("article") or row.get("code") or "").strip()
if not raw:
@@ -125,15 +225,26 @@ def normalize_moysklad_assortment_row(
source_updated_at=_source_updated_at(row),
)
- match = match_product(
- source=source,
- external_id=external_id,
- external_sku=external_sku,
- barcode=barcode,
+ existing_binding = (
+ ProductBinding.objects.select_related("product")
+ .filter(source=source, external_id=external_id)
+ .first()
)
+ product = existing_binding.product if existing_binding else None
+ if product is None and str((row.get("meta") or {}).get("type") or "") != "variant":
+ candidate = match_product(
+ source=source,
+ external_id=external_id,
+ external_sku=external_sku,
+ barcode=barcode,
+ )
+ if candidate.product and not ProductBinding.objects.filter(
+ product=candidate.product,
+ source=source,
+ ).exists():
+ product = candidate.product
created = False
- matched = match.matched
- product = match.product
+ matched = product is not None
if product is None:
product = Product.objects.create(
sku=_candidate_sku(row, external_id),
@@ -145,35 +256,89 @@ def normalize_moysklad_assortment_row(
)
created = True
+ if created or not product.bindings.exclude(source=source).exists():
+ _update_product_from_source(product, row)
+
binding, _ = ProductBinding.objects.update_or_create(
source=source,
external_id=external_id,
defaults={
"product": product,
"external_sku": external_sku[:128],
+ "metadata": _binding_metadata(row),
"is_primary": True,
},
)
-
- has_stock = any(key in row for key in ("stock", "reserve", "inTransit"))
- if has_stock and product.kind != ProductKind.SERVICE:
- StockSnapshot.objects.update_or_create(
- product=product,
+ parent_external_id = str(binding.metadata.get("parent_external_id") or "")
+ if parent_external_id:
+ parent_binding = ProductBinding.objects.filter(
source=source,
- warehouse_external_id="all",
+ external_id=parent_external_id,
+ ).first()
+ if parent_binding and parent_binding.product_id != product.pk:
+ product.parent_id = parent_binding.product_id
+ product.save(update_fields=["parent", "updated_at"])
+ has_stock = any(key in row for key in ("stock", "reserve", "inTransit"))
+ return created, matched, has_stock and product.kind != ProductKind.SERVICE
+
+
+def reconcile_moysklad_variant_parents(source) -> int:
+ updated = 0
+ bindings = {
+ binding.external_id: binding
+ for binding in ProductBinding.objects.filter(source=source).select_related(
+ "product"
+ )
+ }
+ for binding in bindings.values():
+ parent_external_id = str(binding.metadata.get("parent_external_id") or "")
+ parent_binding = bindings.get(parent_external_id)
+ if not parent_binding or parent_binding.product_id == binding.product_id:
+ continue
+ if binding.product.parent_id != parent_binding.product_id:
+ binding.product.parent_id = parent_binding.product_id
+ binding.product.save(update_fields=["parent", "updated_at"])
+ updated += 1
+ return updated
+
+
+@transaction.atomic
+def normalize_moysklad_stock_row(
+ *, connection: IntegrationConnection, row: dict[str, Any], captured_at: datetime
+) -> int:
+ source = connection.source
+ external_product_id = _external_id(row)
+ binding = ProductBinding.objects.filter(
+ source=source,
+ external_id=external_product_id,
+ ).first()
+ if not binding:
+ return 0
+ created = 0
+ for store_stock in row.get("stockByStore") or []:
+ if not isinstance(store_stock, dict):
+ continue
+ warehouse_external_id = _reference_external_id(store_stock)
+ if not warehouse_external_id:
+ continue
+ StockSnapshot.objects.update_or_create(
+ product=binding.product,
+ source=source,
+ warehouse_external_id=warehouse_external_id,
captured_at=captured_at,
defaults={
- "warehouse_name": "Все склады",
- "quantity": _decimal(row.get("stock")),
- "reserved": _decimal(row.get("reserve")),
- "in_transit": _decimal(row.get("inTransit")),
+ "warehouse_name": str(store_stock.get("name") or "")[:255],
+ "quantity": _decimal(store_stock.get("stock")),
+ "reserved": _decimal(store_stock.get("reserve")),
+ "in_transit": _decimal(store_stock.get("inTransit")),
"metadata": {
"provider": "moysklad",
- "external_product_id": binding.external_id,
+ "external_product_id": external_product_id,
},
},
)
- return created, matched, has_stock and product.kind != ProductKind.SERVICE
+ created += 1
+ return created
def _document_positions(row: dict[str, Any]) -> list[dict[str, Any]]:
@@ -187,11 +352,86 @@ def _assortment_external_id(position: dict[str, Any]) -> str:
assortment = position.get("assortment") or {}
if not isinstance(assortment, dict):
return ""
- value = str(assortment.get("id") or "").strip()
- if value:
- return value
- href = str(assortment.get("meta", {}).get("href") or "").rstrip("/")
- return href.rsplit("/", 1)[-1]
+ return _reference_external_id(assortment)
+
+
+def _document_currency(row: dict[str, Any]) -> str:
+ rate = row.get("rate") if isinstance(row.get("rate"), dict) else {}
+ currency = (
+ rate.get("currency") if isinstance(rate.get("currency"), dict) else {}
+ )
+ value = str(
+ currency.get("isoCode")
+ or currency.get("code")
+ or currency.get("name")
+ or "RUB"
+ ).upper()
+ return value[:3] if len(value) >= 3 else "RUB"
+
+
+def _document_channel(
+ row: dict[str, Any], channel_names: dict[str, str] | None
+) -> str:
+ sales_channel = (
+ row.get("salesChannel") if isinstance(row.get("salesChannel"), dict) else {}
+ )
+ name = str(sales_channel.get("name") or "").strip()
+ if not name and channel_names:
+ name = channel_names.get(_reference_external_id(sales_channel), "")
+ return (name or "Не указан")[:64]
+
+
+def _position_cost(position: dict[str, Any], quantity: Decimal) -> tuple[Decimal, str]:
+ position_cost = _money(position.get("cost"))
+ if position_cost > 0:
+ return (quantity * position_cost).quantize(Decimal("0.01")), "position.cost"
+ assortment = (
+ position.get("assortment")
+ if isinstance(position.get("assortment"), dict)
+ else {}
+ )
+ buy_price = assortment.get("buyPrice")
+ unit_cost = (
+ _money(buy_price.get("value"))
+ if isinstance(buy_price, dict)
+ else Decimal("0")
+ )
+ return (quantity * unit_cost).quantize(Decimal("0.01")), (
+ "assortment.buyPrice" if unit_cost > 0 else "unavailable"
+ )
+
+
+def _historical_product_from_document(
+ *, source, assortment: dict[str, Any], external_id: str
+) -> Product:
+ existing = ProductBinding.objects.select_related("product").filter(
+ source=source,
+ external_id=external_id,
+ ).first()
+ if existing:
+ return existing.product
+ external_sku = str(
+ assortment.get("article") or assortment.get("code") or ""
+ ).strip()
+ product = Product.objects.create(
+ sku=_candidate_sku(assortment, external_id),
+ name=str(assortment.get("name") or external_sku or external_id)[:255],
+ barcode=_barcode(assortment)[:64],
+ description=str(assortment.get("description") or ""),
+ kind=_product_kind(assortment),
+ is_active=False,
+ )
+ metadata = _binding_metadata(assortment)
+ metadata["historical_from_document"] = True
+ ProductBinding.objects.create(
+ product=product,
+ source=source,
+ external_id=external_id,
+ external_sku=external_sku[:128],
+ metadata=metadata,
+ is_primary=True,
+ )
+ return product
@transaction.atomic
@@ -200,6 +440,7 @@ def normalize_moysklad_document(
connection: IntegrationConnection,
row: dict[str, Any],
document_type: str,
+ channel_names: dict[str, str] | None = None,
) -> int:
source = connection.source
source_id = _external_id(row)
@@ -218,9 +459,6 @@ def normalize_moysklad_document(
)
occurred_at = _source_updated_at({"updated": row.get("moment")}) or timezone.now()
state = row.get("state") if isinstance(row.get("state"), dict) else {}
- sales_channel = (
- row.get("salesChannel") if isinstance(row.get("salesChannel"), dict) else {}
- )
sale, _ = Sale.objects.update_or_create(
source=source,
external_id=external_id,
@@ -228,10 +466,10 @@ def normalize_moysklad_document(
"source_record": source_result.record,
"event_type": event_type,
"number": str(row.get("name") or "")[:128],
- "channel": str(sales_channel.get("name") or "moysklad")[:64],
+ "channel": _document_channel(row, channel_names),
"status": str(state.get("name") or "")[:128],
"counts_as_sale": bool(row.get("applicable", True)),
- "currency": "RUB",
+ "currency": _document_currency(row),
"occurred_at": occurred_at,
"source_updated_at": _source_updated_at(row),
},
@@ -253,23 +491,37 @@ def normalize_moysklad_document(
external_sku=external_sku,
barcode=barcode,
)
+ matched_product = match.product
+ if matched_product is None and product_external_id:
+ matched_product = _historical_product_from_document(
+ source=source,
+ assortment=assortment,
+ external_id=product_external_id,
+ )
quantity = _decimal(position.get("quantity"))
gross = (quantity * _money(position.get("price"))).quantize(Decimal("0.01"))
discount_percent = _decimal(position.get("discount"))
discount = (gross * discount_percent / Decimal("100")).quantize(Decimal("0.01"))
+ cost_amount, cost_source = _position_cost(position, quantity)
line, _ = SaleLine.objects.update_or_create(
sale=sale,
external_id=position_id,
defaults={
- "product": match.product,
+ "product": matched_product,
"external_product_id": product_external_id[:255],
"external_sku": external_sku[:128],
"name": str(assortment.get("name") or "")[:255],
"quantity": quantity,
"gross_amount": gross,
"discount_amount": min(discount, gross),
- "cost_amount": Decimal("0"),
- "metadata": {"provider": "moysklad"},
+ "cost_amount": cost_amount,
+ "metadata": {
+ "provider": "moysklad",
+ "cost_source": cost_source,
+ "vat": position.get("vat"),
+ "vat_enabled": bool(position.get("vatEnabled")),
+ "overhead": str(_money(position.get("overhead"))),
+ },
},
)
retained_line_ids.append(line.pk)
@@ -277,7 +529,11 @@ def normalize_moysklad_document(
return len(retained_line_ids)
-def sync_moysklad_connection(connection: IntegrationConnection) -> MoySkladSyncStats:
+def sync_moysklad_connection(
+ connection: IntegrationConnection,
+ *,
+ progress_callback: Callable[[MoySkladSyncStats], None] | None = None,
+) -> MoySkladSyncStats:
credentials = decrypt_credentials(connection)
stats = MoySkladSyncStats()
captured_at = timezone.now()
@@ -285,7 +541,7 @@ def sync_moysklad_connection(connection: IntegrationConnection) -> MoySkladSyncS
auth_type=connection.auth_type, credentials=credentials
) as client:
for row in client.iter_assortment():
- created, matched, stock_created = normalize_moysklad_assortment_row(
+ created, matched, _has_stock = normalize_moysklad_assortment_row(
connection=connection,
row=row,
captured_at=captured_at,
@@ -293,15 +549,52 @@ def sync_moysklad_connection(connection: IntegrationConnection) -> MoySkladSyncS
stats.processed += 1
stats.created_products += int(created)
stats.matched_products += int(matched)
- stats.stock_snapshots += int(stock_created)
+ try:
+ stats.images += sync_moysklad_product_images(
+ connection=connection,
+ row=row,
+ client=client,
+ )
+ except Exception as error: # noqa: BLE001 - one image must not abort facts
+ stats.image_errors += 1
+ logger.warning(
+ "MoySklad product images could not be synchronized",
+ extra={
+ "event": "commerce.product.images_sync_failed",
+ "connection_id": str(connection.pk),
+ "external_product_id": str(row.get("id") or ""),
+ "entity_type": str((row.get("meta") or {}).get("type") or ""),
+ "error_type": type(error).__name__,
+ "failure_reason": str(error)[:500],
+ },
+ )
+ if progress_callback and stats.processed % 25 == 0:
+ progress_callback(stats)
+ reconcile_moysklad_variant_parents(connection.source)
+ for stock_row in client.iter_stock_by_store():
+ stats.stock_snapshots += normalize_moysklad_stock_row(
+ connection=connection,
+ row=stock_row,
+ captured_at=captured_at,
+ )
+ StockSnapshot.objects.filter(
+ source=connection.source,
+ warehouse_external_id="all",
+ ).delete()
+ channel_names = client.sales_channel_names()
for document_type in ("demand", "salesreturn"):
for row in client.iter_documents(document_type):
stats.sale_lines += normalize_moysklad_document(
connection=connection,
row=row,
document_type=document_type,
+ channel_names=channel_names,
)
stats.sales += 1
+ if progress_callback and stats.sales % 25 == 0:
+ progress_callback(stats)
+ if progress_callback:
+ progress_callback(stats)
return stats
diff --git a/backend/commerce/services/product_merge.py b/backend/commerce/services/product_merge.py
index 833d8b4c..b7aafa10 100644
--- a/backend/commerce/services/product_merge.py
+++ b/backend/commerce/services/product_merge.py
@@ -2,7 +2,7 @@ from dataclasses import dataclass
from django.db import transaction
-from commerce.models import Cost, Product, SaleLine, StockSnapshot
+from commerce.models import Cost, Product, ProductImage, SaleLine, StockSnapshot
@dataclass(frozen=True)
@@ -31,6 +31,7 @@ def merge_products(
for product in sources:
product.bindings.update(product=locked_target)
+ ProductImage.objects.filter(product=product).update(product=locked_target)
SaleLine.objects.filter(product=product).update(product=locked_target)
Cost.objects.filter(product=product).update(product=locked_target)
for snapshot in StockSnapshot.objects.filter(product=product):
diff --git a/backend/commerce/signals.py b/backend/commerce/signals.py
new file mode 100644
index 00000000..d02526e1
--- /dev/null
+++ b/backend/commerce/signals.py
@@ -0,0 +1,19 @@
+from django.db import transaction
+from django.db.models.signals import post_delete
+from django.dispatch import receiver
+
+from commerce.models import ProductImage
+
+
+def _delete_file_after_commit(field_file):
+ if not field_file or not field_file.name:
+ return
+ storage = field_file.storage
+ name = field_file.name
+ transaction.on_commit(lambda: storage.delete(name))
+
+
+@receiver(post_delete, sender=ProductImage)
+def delete_product_image_files(sender, instance, **kwargs):
+ _delete_file_after_commit(instance.file)
+ _delete_file_after_commit(instance.thumbnail)
diff --git a/backend/commerce/tasks/__init__.py b/backend/commerce/tasks/__init__.py
index 34899c9d..56f905cb 100644
--- a/backend/commerce/tasks/__init__.py
+++ b/backend/commerce/tasks/__init__.py
@@ -9,6 +9,7 @@ from django.utils import timezone
from commerce.integrations import sync_registered_source
from commerce.integrations.moysklad import MoySkladError
from commerce.models import IntegrationConnection, IntegrationStatus, IntegrationSyncRun
+from commerce.realtime import send_connection_update
from commerce.services import sync_moysklad_connection
from commerce.services.credentials import CredentialError
@@ -42,6 +43,7 @@ def enqueue_connection_sync(connection):
"updated_at",
]
)
+ send_connection_update(locked, run=run)
try:
async_result = sync_moysklad_run.delay(str(run.pk))
except Exception:
@@ -56,6 +58,9 @@ def enqueue_connection_sync(connection):
last_error="Не удалось поставить синхронизацию в очередь.",
next_sync_at=retry_at,
)
+ connection.refresh_from_db()
+ run.refresh_from_db()
+ send_connection_update(connection, run=run)
raise
task_id = async_result.id or ""
IntegrationSyncRun.objects.filter(pk=run.pk).update(task_id=task_id)
@@ -90,10 +95,40 @@ def sync_moysklad_run(self, run_id: str):
connection.save(
update_fields=["status", "last_sync_started_at", "last_error", "updated_at"]
)
+ send_connection_update(connection, run=run)
+
+ def report_progress(current_stats):
+ now = timezone.now()
+ IntegrationSyncRun.objects.filter(pk=run.pk).update(
+ processed=current_stats.processed,
+ created_products=current_stats.created_products,
+ matched_products=current_stats.matched_products,
+ stock_snapshots=current_stats.stock_snapshots,
+ images=current_stats.images,
+ image_errors=current_stats.image_errors,
+ sales=current_stats.sales,
+ )
+ IntegrationConnection.objects.filter(pk=connection.pk).update(updated_at=now)
+ run.refresh_from_db()
+ connection.refresh_from_db()
+ send_connection_update(connection, run=run)
+
try:
- stats = sync_moysklad_connection(connection).to_dict()
+ stats = sync_moysklad_connection(
+ connection,
+ progress_callback=report_progress,
+ ).to_dict()
except Exception as exc:
- logger.exception("MoySklad synchronization failed", extra={"run_id": run_id})
+ logger.exception(
+ "MoySklad synchronization failed",
+ extra={
+ "event": "commerce.connection.sync_failed",
+ "run_id": run_id,
+ "connection_id": str(connection.pk),
+ "provider": connection.provider,
+ "error_type": type(exc).__name__,
+ },
+ )
if isinstance(exc, (MoySkladError, CredentialError)):
message = str(exc)
else:
@@ -116,6 +151,7 @@ def sync_moysklad_run(self, run_id: str):
"updated_at",
]
)
+ send_connection_update(connection, run=run)
raise
finished_at = timezone.now()
@@ -124,6 +160,9 @@ def sync_moysklad_run(self, run_id: str):
run.created_products = stats["created_products"]
run.matched_products = stats["matched_products"]
run.stock_snapshots = stats["stock_snapshots"]
+ run.images = stats["images"]
+ run.image_errors = stats["image_errors"]
+ run.sales = stats["sales"]
run.finished_at = finished_at
run.save(
update_fields=[
@@ -132,6 +171,9 @@ def sync_moysklad_run(self, run_id: str):
"created_products",
"matched_products",
"stock_snapshots",
+ "images",
+ "image_errors",
+ "sales",
"finished_at",
]
)
@@ -150,6 +192,7 @@ def sync_moysklad_run(self, run_id: str):
"updated_at",
]
)
+ send_connection_update(connection, run=run)
return stats
diff --git a/backend/docs/architecture/websocket-asyncapi.yaml b/backend/docs/architecture/websocket-asyncapi.yaml
index 0092a088..630cff00 100644
--- a/backend/docs/architecture/websocket-asyncapi.yaml
+++ b/backend/docs/architecture/websocket-asyncapi.yaml
@@ -1,7 +1,7 @@
asyncapi: 2.6.0
info:
title: EUSRR Realtime WebSocket API
- version: 1.3.0
+ version: 1.4.0
description: |
Отдельная AsyncAPI-спецификация для WebSocket-контура EUSRR.
@@ -38,6 +38,7 @@ channels:
- typing indicators
- реакции и голосования
- procurement updates
+ - статусы commerce-интеграций и фоновых синхронизаций
- подписки на диапазоны календаря и calendar snapshots
publish:
operationId: clientAction
@@ -84,6 +85,7 @@ channels:
- $ref: '#/components/messages/NotificationReadEvent'
- $ref: '#/components/messages/NotificationsReadAllEvent'
- $ref: '#/components/messages/ProcurementUpdateEvent'
+ - $ref: '#/components/messages/CommerceConnectionUpdatedEvent'
- $ref: '#/components/messages/TaskBoardUpdateEvent'
- $ref: '#/components/messages/CalendarUpdateEvent'
- $ref: '#/components/messages/CalendarSnapshotEvent'
@@ -212,6 +214,91 @@ components:
is_recurring:
type: boolean
additionalProperties: true
+ CommerceSyncRunPayload:
+ type: object
+ required: [id, connection, status, processed, created_products, matched_products, stock_snapshots, images, image_errors, sales]
+ properties:
+ id:
+ type: string
+ connection:
+ type: string
+ status:
+ type: string
+ task_id:
+ type: string
+ processed:
+ type: integer
+ created_products:
+ type: integer
+ matched_products:
+ type: integer
+ stock_snapshots:
+ type: integer
+ images:
+ type: integer
+ image_errors:
+ type: integer
+ sales:
+ type: integer
+ error:
+ type: string
+ started_at:
+ type: string
+ format: date-time
+ nullable: true
+ finished_at:
+ type: string
+ format: date-time
+ nullable: true
+ created_at:
+ type: string
+ format: date-time
+ additionalProperties: false
+ CommerceConnectionUpdatePayload:
+ type: object
+ required: [id, status, auto_sync, sync_interval_minutes, updated_at]
+ properties:
+ id:
+ type: string
+ status:
+ type: string
+ account_name:
+ type: string
+ last_error:
+ type: string
+ last_tested_at:
+ type: string
+ format: date-time
+ nullable: true
+ last_sync_started_at:
+ type: string
+ format: date-time
+ nullable: true
+ last_sync_finished_at:
+ type: string
+ format: date-time
+ nullable: true
+ last_stats:
+ type: object
+ additionalProperties: true
+ task_id:
+ type: string
+ auto_sync:
+ type: boolean
+ sync_interval_minutes:
+ type: integer
+ next_sync_at:
+ type: string
+ format: date-time
+ nullable: true
+ updated_at:
+ type: string
+ format: date-time
+ recent_run:
+ oneOf:
+ - $ref: '#/components/schemas/CommerceSyncRunPayload'
+ - type: 'null'
+ additionalProperties: false
messages:
OpenChatAction:
name: OpenChatAction
@@ -726,6 +813,19 @@ components:
data:
type: object
additionalProperties: true
+ CommerceConnectionUpdatedEvent:
+ name: CommerceConnectionUpdatedEvent
+ title: Изменение состояния commerce-интеграции
+ payload:
+ type: object
+ required: [type, connection]
+ properties:
+ type:
+ type: string
+ enum: [commerce_connection_updated]
+ connection:
+ $ref: '#/components/schemas/CommerceConnectionUpdatePayload'
+ additionalProperties: false
TaskBoardUpdateEvent:
name: TaskBoardUpdateEvent
payload:
diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py
index 9736c1ab..82b4be81 100644
--- a/backend/eusrr_backend/settings.py
+++ b/backend/eusrr_backend/settings.py
@@ -329,6 +329,19 @@ LOGGING = {
"level": os.getenv("LDAP_LOG_LEVEL", "WARNING").upper(),
"propagate": False,
},
+ # Низкоуровневые стадии TCP/TLS и дублирующие access-записи внешних
+ # HTTP-вызовов создают log storm. Бизнес-результат интеграции пишет
+ # commerce, а транспорт сохраняем только при реальном предупреждении.
+ "httpcore": {
+ "handlers": ["console"],
+ "level": os.getenv("HTTPCORE_LOG_LEVEL", "WARNING").upper(),
+ "propagate": False,
+ },
+ "httpx": {
+ "handlers": ["console"],
+ "level": os.getenv("HTTPX_LOG_LEVEL", "WARNING").upper(),
+ "propagate": False,
+ },
"celery": {
"handlers": ["console"],
"level": os.getenv("CELERY_LOG_LEVEL", "INFO").upper(),
diff --git a/backend/realtime/consumers.py b/backend/realtime/consumers.py
index b52ca736..c3763544 100644
--- a/backend/realtime/consumers.py
+++ b/backend/realtime/consumers.py
@@ -24,6 +24,7 @@ from django.utils import timezone
from communications.consumers import ChatConsumerMixin
from communications.services import ChatNotificationReadService
+from commerce.realtime import COMMERCE_CONNECTIONS_GROUP
from scheduling.realtime import (
MAX_CALENDAR_SUBSCRIPTIONS,
CalendarSubscriptionError,
@@ -150,6 +151,13 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
notifications_channel = f"notifications_{self.user.id}"
await self.channel_layer.group_add(notifications_channel, self.channel_name)
+ # Статусы интеграций не требуют отдельного сокета: все сотрудники уже
+ # имеют read-доступ к commerce API, поэтому используем общий канал.
+ await self.channel_layer.group_add(
+ COMMERCE_CONNECTIONS_GROUP,
+ self.channel_name,
+ )
+
await self.accept()
# Запускаем ping цикл для keepalive
@@ -205,6 +213,10 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
await self.channel_layer.group_discard(
notifications_channel, self.channel_name
)
+ await self.channel_layer.group_discard(
+ COMMERCE_CONNECTIONS_GROUP,
+ self.channel_name,
+ )
logger.debug(
"Realtime consumer disconnected",
@@ -263,14 +275,15 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
)
return
- logger.debug(
- "WebSocket action received",
- extra={
- "event": "websocket.action.received",
- "action": action,
- "active_chat_id": self.active_chat_id,
- },
- )
+ if action != "pong":
+ logger.debug(
+ "WebSocket action received",
+ extra={
+ "event": "websocket.action.received",
+ "action": action,
+ "active_chat_id": self.active_chat_id,
+ },
+ )
handler_name = self.ACTION_HANDLERS.get(action)
if handler_name is None:
@@ -540,6 +553,15 @@ class UserConsumer(ChatConsumerMixin, AsyncJsonWebsocketConsumer):
}
)
+ async def commerce_connection_updated(self, event):
+ """Передаёт изменение статуса интеграции через общий WebSocket."""
+ await self.send_json(
+ {
+ "type": "commerce_connection_updated",
+ "connection": event.get("connection", {}),
+ }
+ )
+
# ==================== Обработка событий закупок ====================
async def poll_update(self, event):
diff --git a/backend/tests/commerce/test_api.py b/backend/tests/commerce/test_api.py
index 24ef1e7e..659013cc 100644
--- a/backend/tests/commerce/test_api.py
+++ b/backend/tests/commerce/test_api.py
@@ -1,11 +1,21 @@
from decimal import Decimal
import pytest
+from django.core.files.base import ContentFile
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
-from commerce.models import Cost, Product, Sale, SaleLine, SourceSystem, StockSnapshot
+from commerce.models import (
+ Cost,
+ Product,
+ ProductBinding,
+ ProductImage,
+ Sale,
+ SaleLine,
+ SourceSystem,
+ StockSnapshot,
+)
pytestmark = pytest.mark.django_db
@@ -46,6 +56,52 @@ def test_staff_can_create_product(api_client, user_factory):
assert Product.objects.filter(sku="NEW").exists()
+def test_product_api_exposes_local_image_urls(api_client, user_factory):
+ user = user_factory()
+ source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
+ product = Product.objects.create(sku="PHOTO", name="Товар с фото")
+ image = ProductImage(
+ product=product,
+ source=source,
+ external_id="image-1",
+ filename="photo.jpg",
+ is_primary=True,
+ )
+ image.file.save("image-1.jpg", ContentFile(b"image"), save=False)
+ image.thumbnail.save("image-1.webp", ContentFile(b"thumbnail"), save=False)
+ image.save()
+ api_client.force_authenticate(user=user)
+
+ response = api_client.get(reverse("api:v1:commerce:product-list"))
+
+ assert response.status_code == status.HTTP_200_OK
+ serialized = response.data["results"][0]["images"][0]
+ assert serialized["is_primary"] is True
+ assert serialized["url"].endswith(".jpg")
+ assert serialized["thumbnail_url"].endswith(".webp")
+
+
+def test_deleting_source_in_use_returns_conflict(api_client, user_factory):
+ user = user_factory(staff=True)
+ source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
+ product = Product.objects.create(sku="MS-001", name="Товар")
+ ProductBinding.objects.create(
+ product=product,
+ source=source,
+ external_id="external-1",
+ )
+ api_client.force_authenticate(user=user)
+
+ response = api_client.delete(
+ reverse("api:v1:commerce:source-detail", args=[source.pk])
+ )
+
+ assert response.status_code == status.HTTP_409_CONFLICT
+ assert response.data["code"] == "source_in_use"
+ assert response.data["protected_count"] == 1
+ assert SourceSystem.objects.filter(pk=source.pk).exists()
+
+
def test_staff_can_manage_normalized_commerce_facts(api_client, user_factory):
user = user_factory(staff=True)
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
@@ -109,6 +165,45 @@ def test_staff_can_manage_normalized_commerce_facts(api_client, user_factory):
assert StockSnapshot.objects.filter(pk=stock_response.data["id"]).exists()
+def test_management_searches_costs_and_stock_by_product_or_warehouse(
+ api_client, user_factory
+):
+ user = user_factory(staff=True)
+ source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
+ product = Product.objects.create(sku="SEARCH-42", name="Товар для поиска")
+ Cost.objects.create(
+ source=source,
+ product=product,
+ external_id="cost-42",
+ channel="marketplace",
+ cost_type="commission",
+ amount=Decimal("10"),
+ occurred_at=timezone.now(),
+ )
+ StockSnapshot.objects.create(
+ source=source,
+ product=product,
+ warehouse_external_id="warehouse-42",
+ warehouse_name="Главный склад",
+ quantity=Decimal("5"),
+ captured_at=timezone.now(),
+ )
+ api_client.force_authenticate(user=user)
+
+ cost_response = api_client.get(
+ reverse("api:v1:commerce:cost-list"), {"search": "SEARCH-42"}
+ )
+ stock_response = api_client.get(
+ reverse("api:v1:commerce:stocksnapshot-list"),
+ {"search": "Главный склад"},
+ )
+
+ assert cost_response.status_code == status.HTTP_200_OK
+ assert cost_response.data["count"] == 1
+ assert stock_response.status_code == status.HTTP_200_OK
+ assert stock_response.data["count"] == 1
+
+
def test_product_analytics_endpoint(api_client, user_factory):
user = user_factory()
source = SourceSystem.objects.create(code="insales", name="InSales")
diff --git a/backend/tests/commerce/test_connections.py b/backend/tests/commerce/test_connections.py
index 1a7e6a1b..2904e297 100644
--- a/backend/tests/commerce/test_connections.py
+++ b/backend/tests/commerce/test_connections.py
@@ -1,8 +1,10 @@
from datetime import timedelta
+import io
from unittest.mock import Mock, patch
import httpx
import pytest
+from PIL import Image
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
@@ -12,21 +14,47 @@ from commerce.models import (
IntegrationStatus,
Product,
ProductBinding,
+ ProductImage,
Sale,
SourceRecord,
SourceSystem,
StockSnapshot,
)
-from commerce.integrations.moysklad import MoySkladClient
+from commerce.integrations.moysklad import (
+ MoySkladClient,
+ MoySkladError,
+ MoySkladRemoteImage,
+)
+from commerce.media import (
+ _validated_image_and_thumbnail,
+ sync_moysklad_product_images,
+)
+from commerce.realtime import COMMERCE_CONNECTIONS_GROUP, send_connection_update
from commerce.services.moysklad_sync import (
+ MoySkladSyncStats,
normalize_moysklad_assortment_row,
normalize_moysklad_document,
+ normalize_moysklad_stock_row,
+ reconcile_moysklad_variant_parents,
)
-from commerce.tasks import dispatch_due_connection_syncs
+from commerce.tasks import dispatch_due_connection_syncs, sync_moysklad_run
pytestmark = pytest.mark.django_db
+def _jpeg_bytes():
+ output = io.BytesIO()
+ Image.new("RGB", (80, 60), (42, 133, 210)).save(output, format="JPEG")
+ return output.getvalue()
+
+
+def test_product_thumbnail_rejects_decompression_bomb(monkeypatch):
+ monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 100)
+
+ with pytest.raises((Image.DecompressionBombError, Image.DecompressionBombWarning)):
+ _validated_image_and_thumbnail(_jpeg_bytes())
+
+
def test_moysklad_client_keeps_api_prefix(settings):
settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
requested_urls = []
@@ -49,6 +77,206 @@ def test_moysklad_client_keeps_api_prefix(settings):
assert requested_urls == ["https://api.example/api/remap/1.2/context/employee"]
+def test_moysklad_client_accepts_token_with_bearer_prefix():
+ client = MoySkladClient(
+ auth_type="token",
+ credentials={"token": " Bearer access-token-value "},
+ )
+ try:
+ assert client.client.headers["Authorization"] == "Bearer access-token-value"
+ finally:
+ client.close()
+
+
+def test_moysklad_client_uses_vendor_accept_header():
+ client = MoySkladClient(auth_type="token", credentials={"token": "test"})
+ try:
+ assert client.client.headers["Accept"] == "application/json;charset=utf-8"
+ finally:
+ client.close()
+
+
+def test_moysklad_client_reads_variant_images_and_downloads_file(settings):
+ settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
+ image_content = _jpeg_bytes()
+
+ def handler(request):
+ if request.url.path.endswith("/images"):
+ return httpx.Response(
+ 200,
+ json={
+ "rows": [
+ {
+ "filename": "variant.jpg",
+ "title": "Главное фото",
+ "size": len(image_content),
+ "updated": "2026-08-17 12:00:00.000",
+ "meta": {
+ "href": "https://api.example/api/remap/1.2/entity/variant/product-1/images/image-1",
+ "downloadHref": "https://api.example/download/image-1",
+ },
+ }
+ ]
+ },
+ request=request,
+ )
+ return httpx.Response(
+ 200,
+ content=image_content,
+ headers={"Content-Type": "image/jpeg"},
+ request=request,
+ )
+
+ client = MoySkladClient(auth_type="token", credentials={"token": "test"})
+ client.client.close()
+ client.client = httpx.Client(
+ base_url=client.base_url,
+ headers={"Authorization": "Bearer test"},
+ transport=httpx.MockTransport(handler),
+ )
+ try:
+ images = client.product_images("variant", "product-1")
+ content, content_type = client.download_image(images[0].download_href)
+ finally:
+ client.close()
+
+ assert images[0].external_id == "image-1"
+ assert images[0].filename == "variant.jpg"
+ assert content == image_content
+ assert content_type == "image/jpeg"
+
+
+def test_assortment_expands_images_in_supported_page_size(settings):
+ settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
+ queries = []
+
+ def handler(request):
+ queries.append(dict(request.url.params))
+ return httpx.Response(200, json={"rows": []}, request=request)
+
+ client = MoySkladClient(auth_type="token", credentials={"token": "test"})
+ client.client.close()
+ client.client = httpx.Client(
+ base_url=client.base_url,
+ transport=httpx.MockTransport(handler),
+ )
+ try:
+ assert list(client.iter_assortment(limit=1000)) == []
+ finally:
+ client.close()
+
+ assert queries == [
+ {"limit": "100", "offset": "0", "stockMode": "all", "expand": "images"}
+ ]
+
+
+def test_product_images_are_stored_deduplicated_and_removed_with_source_state():
+ source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
+ connection = IntegrationConnection.objects.create(
+ source=source,
+ provider="moysklad",
+ encrypted_credentials="unused",
+ )
+ row = {
+ "id": "external-product-1",
+ "name": "Товар с фото",
+ "article": "PHOTO-1",
+ "meta": {"type": "variant"},
+ "images": {"meta": {"size": 1}},
+ }
+ normalize_moysklad_assortment_row(
+ connection=connection,
+ row=row,
+ captured_at=timezone.now(),
+ )
+ content = _jpeg_bytes()
+ remote = MoySkladRemoteImage(
+ external_id="image-1",
+ filename="photo.jpg",
+ title="Главное фото",
+ size=len(content),
+ updated="2026-08-17 12:00:00.000",
+ download_href="https://api.example/download/image-1",
+ )
+ client = Mock()
+ client.product_images.return_value = [remote]
+ client.download_image.return_value = (content, "image/jpeg")
+
+ first_count = sync_moysklad_product_images(
+ connection=connection,
+ row=row,
+ client=client,
+ )
+ second_count = sync_moysklad_product_images(
+ connection=connection,
+ row=row,
+ client=client,
+ )
+
+ image = ProductImage.objects.get(source=source, external_id="image-1")
+ assert first_count == second_count == 1
+ assert image.product.sku == "PHOTO-1"
+ assert image.file.name.endswith(".jpg")
+ assert image.thumbnail.name.endswith(".webp")
+ assert image.is_primary is True
+ assert client.download_image.call_count == 1
+
+ client.product_images.return_value = []
+ assert sync_moysklad_product_images(
+ connection=connection,
+ row=row,
+ client=client,
+ ) == 0
+ assert not ProductImage.objects.filter(pk=image.pk).exists()
+
+
+def test_moysklad_client_includes_safe_upstream_error_detail(settings):
+ settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2"
+
+ def handler(request):
+ return httpx.Response(
+ 400,
+ json={"errors": [{"error": "Некорректный токен доступа"}]},
+ request=request,
+ )
+
+ client = MoySkladClient(auth_type="token", credentials={"token": "test"})
+ client.client.close()
+ client.client = httpx.Client(
+ base_url=client.base_url,
+ transport=httpx.MockTransport(handler),
+ )
+ try:
+ with pytest.raises(MoySkladError, match="Некорректный токен доступа"):
+ client.employee_context()
+ finally:
+ client.close()
+
+
+def test_connection_update_is_sent_to_shared_realtime_group():
+ source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
+ connection = IntegrationConnection.objects.create(
+ source=source,
+ provider="moysklad",
+ encrypted_credentials="unused",
+ status=IntegrationStatus.SYNCING,
+ )
+ run = connection.sync_runs.create(status="running")
+ sent = []
+
+ class ChannelLayer:
+ async def group_send(self, group, event):
+ sent.append((group, event))
+
+ with patch("commerce.realtime.get_channel_layer", return_value=ChannelLayer()):
+ send_connection_update(connection, run=run)
+
+ assert sent[0][0] == COMMERCE_CONNECTIONS_GROUP
+ assert sent[0][1]["type"] == "commerce_connection_updated"
+ assert sent[0][1]["connection"]["status"] == IntegrationStatus.SYNCING
+ assert sent[0][1]["connection"]["recent_run"]["id"] == str(run.pk)
+
+
def test_staff_connects_moysklad_without_exposing_token(api_client, user_factory):
user = user_factory(staff=True)
api_client.force_authenticate(user=user)
@@ -117,6 +345,43 @@ def test_connection_sync_action_queues_background_run(api_client, user_factory):
assert connection.task_id == "celery-task-1"
+def test_sync_run_persists_product_image_progress():
+ source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
+ connection = IntegrationConnection.objects.create(
+ source=source,
+ provider="moysklad",
+ encrypted_credentials="unused",
+ )
+ run = connection.sync_runs.create()
+
+ def synchronize(_connection, *, progress_callback):
+ stats = MoySkladSyncStats(
+ processed=25,
+ created_products=4,
+ stock_snapshots=20,
+ images=12,
+ sales=3,
+ )
+ progress_callback(stats)
+ return stats
+
+ with (
+ patch("commerce.tasks.sync_moysklad_connection", side_effect=synchronize),
+ patch("commerce.tasks.send_connection_update"),
+ ):
+ result = sync_moysklad_run.run(str(run.pk))
+
+ run.refresh_from_db()
+ connection.refresh_from_db()
+ assert result["images"] == 12
+ assert run.status == "success"
+ assert run.processed == 25
+ assert run.images == 12
+ assert run.image_errors == 0
+ assert run.sales == 3
+ assert connection.last_stats["images"] == 12
+
+
def test_beat_dispatcher_queues_due_connection():
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
connection = IntegrationConnection.objects.create(
@@ -164,7 +429,7 @@ def test_schedule_update_does_not_disconnect_connection(api_client, user_factory
assert connection.next_sync_at is not None
-def test_moysklad_row_creates_product_binding_raw_record_and_stock():
+def test_moysklad_row_creates_product_binding_and_raw_record():
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
connection = IntegrationConnection.objects.create(
source=source,
@@ -205,9 +470,61 @@ def test_moysklad_row_creates_product_binding_raw_record_and_stock():
entity_type="product",
external_id="external-product-1",
).exists()
- snapshot = StockSnapshot.objects.get(product=product, source=source)
- assert snapshot.quantity == 12
- assert snapshot.reserved == 2
+ assert not StockSnapshot.objects.filter(product=product, source=source).exists()
+
+
+def test_moysklad_stock_report_creates_separate_warehouse_snapshots():
+ source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
+ connection = IntegrationConnection.objects.create(
+ source=source,
+ provider="moysklad",
+ encrypted_credentials="unused",
+ )
+ product = Product.objects.create(sku="STOCK-1", name="Товар")
+ ProductBinding.objects.create(
+ source=source,
+ product=product,
+ external_id="external-product-1",
+ )
+ captured_at = timezone.now()
+
+ created = normalize_moysklad_stock_row(
+ connection=connection,
+ captured_at=captured_at,
+ row={
+ "meta": {
+ "href": "https://api.moysklad.ru/api/remap/1.2/entity/product/external-product-1?expand=supplier"
+ },
+ "stockByStore": [
+ {
+ "name": "Основной склад",
+ "stock": 12,
+ "reserve": 2,
+ "inTransit": 3,
+ "meta": {
+ "href": "https://api.moysklad.ru/api/remap/1.2/entity/store/store-1"
+ },
+ },
+ {
+ "name": "Магазин",
+ "stock": 4,
+ "reserve": 1,
+ "inTransit": 0,
+ "meta": {
+ "href": "https://api.moysklad.ru/api/remap/1.2/entity/store/store-2"
+ },
+ },
+ ],
+ },
+ )
+
+ assert created == 2
+ assert set(
+ StockSnapshot.objects.values_list("warehouse_external_id", flat=True)
+ ) == {"store-1", "store-2"}
+ first = StockSnapshot.objects.get(warehouse_external_id="store-1")
+ assert first.quantity == 12
+ assert first.reserved == 2
def test_moysklad_row_matches_existing_product_by_sku():
@@ -241,6 +558,106 @@ def test_moysklad_row_matches_existing_product_by_sku():
assert Product.objects.count() == 1
+def test_moysklad_variant_is_not_merged_with_parent_and_parent_is_reconciled():
+ source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
+ connection = IntegrationConnection.objects.create(
+ source=source,
+ provider="moysklad",
+ encrypted_credentials="unused",
+ )
+ captured_at = timezone.now()
+ variant_row = {
+ "id": "variant-1",
+ "name": "Футболка / XL",
+ "article": "SHIRT-1",
+ "meta": {"type": "variant"},
+ "product": {
+ "meta": {
+ "href": "https://api.moysklad.ru/api/remap/1.2/entity/product/product-1"
+ }
+ },
+ "characteristics": [{"name": "Размер", "value": "XL"}],
+ }
+ parent_row = {
+ "id": "product-1",
+ "name": "Футболка",
+ "article": "SHIRT-1",
+ "meta": {"type": "product"},
+ }
+
+ normalize_moysklad_assortment_row(
+ connection=connection,
+ row=variant_row,
+ captured_at=captured_at,
+ )
+ normalize_moysklad_assortment_row(
+ connection=connection,
+ row=parent_row,
+ captured_at=captured_at,
+ )
+ assert reconcile_moysklad_variant_parents(source) == 1
+
+ variant = ProductBinding.objects.get(
+ source=source,
+ external_id="variant-1",
+ )
+ parent = ProductBinding.objects.get(
+ source=source,
+ external_id="product-1",
+ )
+ assert variant.product != parent.product
+ assert variant.product.parent == parent.product
+ assert variant.metadata["characteristics"] == [
+ {"name": "Размер", "value": "XL"}
+ ]
+
+
+def test_moysklad_existing_binding_refreshes_product_fields():
+ source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
+ connection = IntegrationConnection.objects.create(
+ source=source,
+ provider="moysklad",
+ encrypted_credentials="unused",
+ )
+ captured_at = timezone.now()
+ row = {
+ "id": "product-1",
+ "name": "Старое название",
+ "article": "REFRESH-1",
+ "meta": {"type": "product"},
+ }
+ normalize_moysklad_assortment_row(
+ connection=connection,
+ row=row,
+ captured_at=captured_at,
+ )
+ row.update(
+ {
+ "name": "Новое название",
+ "description": "Новое описание",
+ "barcodes": [{"ean13": "460000000002"}],
+ "archived": True,
+ }
+ )
+
+ created, matched, _ = normalize_moysklad_assortment_row(
+ connection=connection,
+ row=row,
+ captured_at=captured_at,
+ )
+
+ product = ProductBinding.objects.get(
+ source=source,
+ external_id="product-1",
+ ).product
+ assert created is False
+ assert matched is True
+ assert product.name == "Новое название"
+ assert product.description == "Новое описание"
+ assert product.barcode == "460000000002"
+ assert product.is_active is False
+
+
def test_moysklad_demand_creates_sale_and_matches_lines():
source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
connection = IntegrationConnection.objects.create(
@@ -262,6 +679,11 @@ def test_moysklad_demand_creates_sale_and_matches_lines():
"updated": "2026-08-17 13:10:00.000",
"applicable": True,
"state": {"name": "Отгружен"},
+ "salesChannel": {
+ "meta": {
+ "href": "https://api.moysklad.ru/api/remap/1.2/entity/saleschannel/channel-1"
+ }
+ },
"positions": {
"rows": [
{
@@ -273,6 +695,7 @@ def test_moysklad_demand_creates_sale_and_matches_lines():
"id": "external-product-1",
"name": "Товар",
"article": "SALE-001",
+ "buyPrice": {"value": 7000},
},
}
]
@@ -283,13 +706,64 @@ def test_moysklad_demand_creates_sale_and_matches_lines():
connection=connection,
row=row,
document_type="demand",
+ channel_names={"channel-1": "robotail.pro"},
)
assert line_count == 1
sale = Sale.objects.get(source=source, external_id="demand:demand-1")
assert sale.number == "00042"
assert sale.status == "Отгружен"
+ assert sale.channel == "robotail.pro"
line = sale.lines.get()
assert line.product == product
assert line.gross_amount == 300
assert line.discount_amount == 30
+ assert line.cost_amount == 140
+ assert line.metadata["cost_source"] == "assortment.buyPrice"
+
+
+def test_moysklad_document_retains_product_missing_from_current_assortment():
+ source = SourceSystem.objects.create(code="moysklad", name="МойСклад")
+ connection = IntegrationConnection.objects.create(
+ source=source,
+ provider="moysklad",
+ encrypted_credentials="unused",
+ )
+ row = {
+ "id": "demand-historical",
+ "name": "00043",
+ "moment": "2026-08-17 13:00:00.000",
+ "positions": {
+ "rows": [
+ {
+ "id": "position-historical",
+ "quantity": 1,
+ "price": 10000,
+ "discount": 0,
+ "assortment": {
+ "id": "deleted-product-1",
+ "name": "Удалённый товар",
+ "article": "OLD-001",
+ "meta": {"type": "product"},
+ },
+ }
+ ]
+ },
+ }
+
+ normalize_moysklad_document(
+ connection=connection,
+ row=row,
+ document_type="demand",
+ )
+
+ line = Sale.objects.get(external_id="demand:demand-historical").lines.get()
+ assert line.product is not None
+ assert line.product.name == "Удалённый товар"
+ assert line.product.is_active is False
+ binding = ProductBinding.objects.get(
+ source=source,
+ external_id="deleted-product-1",
+ )
+ assert binding.product == line.product
+ assert binding.metadata["historical_from_document"] is True
diff --git a/backend/tests/realtime/test_user_consumer.py b/backend/tests/realtime/test_user_consumer.py
index bb087bbf..b9b289f4 100644
--- a/backend/tests/realtime/test_user_consumer.py
+++ b/backend/tests/realtime/test_user_consumer.py
@@ -15,6 +15,7 @@ from channels.db import database_sync_to_async
from django.contrib.auth.models import AnonymousUser
from communications.models import Chat, ChatMembership
+from commerce.realtime import COMMERCE_CONNECTIONS_GROUP
from notifications.models import Notification
pytestmark = pytest.mark.django_db(transaction=True)
@@ -114,6 +115,28 @@ class TestUserConsumerTaskBoardEvents:
await communicator.disconnect()
+
+@pytest.mark.asyncio
+async def test_user_receives_commerce_connection_update(ws_communicator, user):
+ communicator = await ws_communicator(user=user)
+ connected, _ = await communicator.connect()
+ assert connected
+
+ await get_channel_layer().group_send(
+ COMMERCE_CONNECTIONS_GROUP,
+ {
+ "type": "commerce_connection_updated",
+ "connection": {"id": "connection-1", "status": "ready"},
+ },
+ )
+
+ response = await communicator.receive_json_from(timeout=5)
+ assert response == {
+ "type": "commerce_connection_updated",
+ "connection": {"id": "connection-1", "status": "ready"},
+ }
+ await communicator.disconnect()
+
@pytest.mark.asyncio
class TestUserConsumerChatManagement:
"""Тесты управления активным чатом."""
diff --git a/backend/tests/test_logging.py b/backend/tests/test_logging.py
index d468dd85..d2327ceb 100644
--- a/backend/tests/test_logging.py
+++ b/backend/tests/test_logging.py
@@ -86,6 +86,10 @@ def test_logging_settings_use_one_context_aware_console_pipeline():
assert config["loggers"]["daphne"]["propagate"] is False
assert config["loggers"]["django.channels.server"]["level"] == "WARNING"
assert config["loggers"]["django.channels.server"]["propagate"] is False
+ assert config["loggers"]["httpcore"]["level"] == "WARNING"
+ assert config["loggers"]["httpcore"]["propagate"] is False
+ assert config["loggers"]["httpx"]["level"] == "WARNING"
+ assert config["loggers"]["httpx"]["propagate"] is False
def test_context_filter_assigns_stable_event_to_framework_logs():
diff --git a/docs/guides/COMMERCE.md b/docs/guides/COMMERCE.md
new file mode 100644
index 00000000..82cef1f0
--- /dev/null
+++ b/docs/guides/COMMERCE.md
@@ -0,0 +1,55 @@
+# Commerce и МойСклад
+
+Commerce-контур хранит нормализованные товары, внешние привязки, продажи,
+возвраты, расходы, остатки и локальные изображения товаров. Источником истины
+для структуры данных служат модели `backend/commerce/models/`, для HTTP API —
+`backend/api/v1/commerce/`, для импорта —
+`backend/commerce/services/moysklad_sync.py`.
+
+## Импорт
+
+Полная синхронизация запускается Celery-задачей
+`commerce.sync_moysklad_run`. Для работы ручного импорта нужен Celery worker,
+для расписания — также Celery Beat.
+
+Импорт выполняет следующие этапы:
+
+1. Загружает ассортимент страницами по 100 позиций с `expand=images`.
+2. Нормализует товары, модификации, комплекты, привязки и остатки.
+3. Скачивает оригиналы изображений по авторизованному `downloadHref`, проверяет
+ формат и лимит 25 МБ, создаёт локальную WebP-миниатюру.
+4. Импортирует отгрузки и возвраты вместе с позициями.
+5. Сохраняет итоговую статистику и публикует состояние через общий WebSocket.
+
+Прогресс запуска сохраняется и отправляется frontend каждые 25 товаров или
+документов. Ошибка отдельного изображения учитывается в `image_errors`, но не
+отменяет импорт товарных и финансовых данных.
+
+## Изображения
+
+`ProductImage` хранит оригинал и миниатюру в `MEDIA_ROOT/commerce/products/`.
+Временные внешние ссылки МойСклад не возвращаются клиенту и не используются как
+публичные URL. Повторная синхронизация сравнивает внешний размер и время
+изменения, поэтому неизменённое изображение не скачивается повторно. Удалённые
+в источнике изображения удаляются локально после успешного чтения списка.
+
+REST `ProductSerializer` возвращает `images[]` с локальными `url` и
+`thumbnail_url`. Каталог использует миниатюру, карточка товара — оригинал для
+полноэкранного просмотра.
+
+## Realtime и fallback
+
+Событие `commerce_connection_updated` содержит состояние подключения и
+последнего запуска, включая `processed`, `images` и `sales`. При рабочем
+WebSocket frontend не опрашивает connections endpoint. Если WebSocket
+недоступен во время активного импорта, включается REST fallback раз в 30 секунд.
+
+## Проверка
+
+```bash
+cd backend
+pytest tests/commerce -q
+python manage.py makemigrations --check --dry-run
+python manage.py check
+ruff check commerce api/v1/commerce tests/commerce
+```
diff --git a/docs/guides/README.md b/docs/guides/README.md
index 473de505..79e92030 100644
--- a/docs/guides/README.md
+++ b/docs/guides/README.md
@@ -8,6 +8,7 @@
- [LDAP.md](LDAP.md) — LDAP/AD authentication, write-back, sync, retry queue и admin.
- [WEBSOCKET.md](WEBSOCKET.md) — единый `/ws/`, JWT, actions, events и production proxy.
- [NOTIFICATIONS.md](NOTIFICATIONS.md) — создание, доставка, REST, realtime, email и Web Push.
+- [COMMERCE.md](COMMERCE.md) — каталог, МойСклад, изображения, Celery и realtime-прогресс.
## Frontend
diff --git a/docs/guides/WEBSOCKET.md b/docs/guides/WEBSOCKET.md
index 0098152d..2dd8cd51 100644
--- a/docs/guides/WEBSOCKET.md
+++ b/docs/guides/WEBSOCKET.md
@@ -19,6 +19,7 @@ POST /api/auth/ws-ticket/ → ws://localhost:8000/ws/?ticket=ONE_TIME_TICKET
- singleton frontend connection: `frontend/src/lib/websocketManager.ts`
- chat adapter поверх singleton: `frontend/src/hooks/useWebSocket.ts`
- calendar realtime publisher: `backend/scheduling/realtime.py`
+- commerce realtime publisher: `backend/commerce/realtime.py`
- tests: `backend/tests/realtime/`
AsyncAPI доступен через `/api/ws/schema/` и `/api/ws/docs/`. При расхождении с этим guide ориентируйтесь на consumer и AsyncAPI schema.
@@ -56,6 +57,7 @@ ASGI проверяет `Origin` через `AllowedHostsOriginValidator`. Ес
- подписывается только на доступные активные `chat_{id}`;
- подписывается на `user_{user_id}`;
- подписывается на `notifications_{user_id}`;
+- подписывается на общий read-only канал статусов commerce-интеграций;
- запускает server ping каждые 20 секунд.
Изменение `ChatMembership` синхронизируется с уже открытыми соединениями: при добавлении consumer входит в группу, при отзыве доступа немедленно выходит из неё и закрывает активный чат.
@@ -101,9 +103,15 @@ ASGI проверяет `Origin` через `AllowedHostsOriginValidator`. Ес
- typing: `typing_start`, `typing_stop`;
- polls/read receipts: `poll_update`, `marked_read`;
- notifications: `notification`, `unread_count`, `notification_read`, `notifications_read_all`;
-- domain events: `procurement_update`, `task_board_update`, `calendar_update`;
+- domain events: `procurement_update`, `task_board_update`, `calendar_update`, `commerce_connection_updated`;
- calendar stream: `calendar_snapshot`, `calendar_error`.
+`commerce_connection_updated` содержит безопасный срез состояния подключения и
+последнего запуска синхронизации без credentials. Экран управления применяет
+его к локальному состоянию вместо опроса `/api/v1/commerce/connections/` каждые
+2,5 секунды. При недоступном WebSocket остаётся REST fallback раз в 30 секунд;
+при живом соединении периодический запрос не выполняется.
+
`calendar_subscribe` открывает независимую подписку на диапазон до 400 дней. Одно соединение поддерживает до восьми подписок, поэтому большой и боковой календари могут иметь разные диапазоны. Backend проверяет доступ пользователя, разворачивает recurring occurrences и отправляет `calendar_snapshot` сразу после подписки и после связанных изменений. `scope=all` применяется только для staff/superuser.
`calendar_update` остаётся компактным доменным событием для синхронизации списка календарей. Сами видимые события поступают в `calendar_snapshot`; REST-загрузка используется frontend только как fallback, если WebSocket недоступен. Создание, изменение, удаление, импорт и экспорт календаря остаются HTTP-командами.
diff --git a/frontend/src/app/products/[productId]/page.tsx b/frontend/src/app/products/[productId]/page.tsx
index c0a11b7f..4c53f4b0 100644
--- a/frontend/src/app/products/[productId]/page.tsx
+++ b/frontend/src/app/products/[productId]/page.tsx
@@ -1,20 +1,32 @@
"use client";
import { AppShell } from "@/components/AppShell";
+import MediaPreviewModal from "@/components/shared/MediaPreviewModal";
import { Modal } from "@/components/ui";
import { useUser } from "@/contexts/UserContext";
import { apiClient } from "@/lib/api";
-import type { CommerceProduct, ProductMetrics } from "@/types/commerce";
+import { resolveMediaUrl } from "@/lib/url";
+import type {
+ CommerceProduct,
+ CommerceProductImage,
+ ProductChannelMetrics,
+ ProductMetrics,
+} from "@/types/commerce";
import {
+ AlertTriangle,
ArrowLeft,
- Boxes,
+ ChevronDown,
+ ChevronUp,
+ FileText,
GitMerge,
Link2,
Loader2,
+ PackageCheck,
Search,
TrendingUp,
} from "lucide-react";
import Link from "next/link";
+import Image from "next/image";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
@@ -32,6 +44,31 @@ function formatMoney(value: string, currency: string) {
}).format(Number(value));
}
+function cleanProductDescription(value: string) {
+ return value
+ .replace(/\.?https?:\/\/img\.youtube\.com\/\S+/gi, "")
+ .replace(/https?:\/\/(?:www\.)?youtu(?:\.be|be\.com)\/\S+/gi, "")
+ .replace(/\/\/www\.youtube\.com\/embed\/\S+/gi, "")
+ .replace(/\s{2,}/g, " ")
+ .trim();
+}
+
+const kindLabels: Record
- {product.sku} -
-- {product.description} -
++ Артикул {product.sku || "—"} +
++ {cleanProductDescription(product.description)} +
+ {cleanProductDescription(product.description).length > 420 ? ( + + ) : null} ++
Денежных операций по товару пока нет. -
+ +Разбивки по каналам пока нет.
) : ( -| Канал | -Количество | -Выручка | -Себестоимость | -Расходы | -Маржа | -
|---|---|---|---|---|---|
| {channel.channel} | -{formatDecimal(channel.quantity, 4)} | -{formatMoney(channel.net_revenue, channel.currency)} | -{formatMoney(channel.cost_of_goods, channel.currency)} | -{formatMoney(channel.additional_costs, channel.currency)} | -{formatMoney(channel.contribution_margin, channel.currency)} | -
{label}
-+
{value}
+ Структура дополнительных расходов +
+Массовый импорт товаров, модификаций и текущих остатков с сохранением внешних идентификаторов.
-{new Intl.NumberFormat("ru-RU").format(value)}
{label}
{description}
Ручное управление справочниками и нормализованными фактами.
-Каталог, подключения, продажи и складские данные.
+{subtitle}
{subtitle}
| {item.sku} | {item.name} | {item.kind} | {item.bindings.length} |
| {item.sku} | {item.name} | {productKindLabels[item.kind]} | {item.bindings.length} |
| {item.code} | {item.name} | {item.is_active ? "Активен" : "Отключён"} |
| {productMap.get(item.product)?.sku ?? item.product} | {sourceMap.get(item.source)?.name ?? item.source_code} | {item.external_id} | {item.external_sku || "—"} |
| {sourceMap.get(item.source)?.name ?? item.source_code} | {item.channel || "—"} | {item.status || item.event_type} | {displayDate(item.occurred_at)} | {item.lines.length} |
| {item.product ? productMap.get(item.product)?.name ?? item.product : "По позиции продажи"} | {sourceMap.get(item.source)?.name ?? item.source_code} | {costTypeLabels[item.cost_type] ?? item.cost_type} | {item.channel || "—"} | {displayNumber(item.amount)} {item.currency} | {displayDate(item.occurred_at)} |
| {productMap.get(item.product)?.name ?? item.product} | {sourceMap.get(item.source)?.name ?? item.source_code} | {item.warehouse_name || item.warehouse_external_id} | {displayNumber(item.quantity, 4)} | {displayNumber(item.reserved, 4)} | {displayNumber(item.available, 4)} | {displayDate(item.captured_at)} |
| {header} | )}
|---|
| {header} | )}
|---|
+ Товары +
+ + {count} + +Загрузка…
- ) : products.length === 0 ? ( -- Товары пока не добавлены или не найдены. +
+ Ничего не найдено +
++ Измените поисковый запрос или выберите другой тип товара.
- {product.sku} - {product.bindings.length > 0 - ? ` · ${product.bindings.length} интеграций` - : " · без внешних привязок"} -
-Массовый импорт товаров, модификаций и текущих остатков с сохранением внешних идентификаторов.
+{new Intl.NumberFormat("ru-RU").format(value)}
{label}
{description}