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: "Товар", + service: "Услуга", + bundle: "Комплект", +}; + +const costTypeLabels: Record = { + commission: "Комиссия", + delivery: "Доставка", + storage: "Хранение", + advertising: "Реклама", + acquiring: "Эквайринг", + return: "Возвраты", + other: "Прочее", +}; + export default function ProductDetailPage() { const params = useParams<{ productId: string }>(); const { user } = useUser(); @@ -45,6 +82,9 @@ export default function ProductDetailPage() { const [searching, setSearching] = useState(false); const [merging, setMerging] = useState(false); const [mergeError, setMergeError] = useState(null); + const [previewImage, setPreviewImage] = useState(null); + const [descriptionExpanded, setDescriptionExpanded] = useState(false); + const [expandedChannelKey, setExpandedChannelKey] = useState(null); const permissions = user?.auth?.permissions_by_app?.commerce ?? []; const canManage = Boolean( user?.auth?.is_staff || @@ -131,10 +171,10 @@ export default function ProductDetailPage() { return ( -
+
К каталогу @@ -147,94 +187,178 @@ export default function ProductDetailPage() {

) : ( <> -
-
-
-

- {product.sku} -

-

- {product.name} -

- {product.description ? ( -

- {product.description} -

+
+
+ {product.images[0] ? ( + + ) : null} +
+
+
+
+ + {kindLabels[product.kind]} + + + {product.is_active ? "Активен" : "В архиве"} + +
+

+ Артикул {product.sku || "—"} +

+

+ {product.name} +

+
+ {canManage ? ( + + ) : null} +
+ {product.images.length > 1 ? ( +
+ {product.images.map((image) => ( + + ))} +
) : null}
-
- {canManage ? : null} - - {product.is_active ? "Активен" : "Архив"} - -
-
+ {product.description ? ( +
+
+ +

Описание

+
+

+ {cleanProductDescription(product.description)} +

+ {cleanProductDescription(product.description).length > 420 ? ( + + ) : null} +
+ ) : null} + +
{metrics.monetary.length === 0 ? ( -
-

+

+
+ + + +

Денежных операций по товару пока нет. -

+

+
) : ( metrics.monetary.map((money) => ( -
+

Продажи · {money.currency}

-
+
- +
+ {!money.cost_complete ? ( +
+ + + Себестоимость неизвестна для {money.missing_cost_lines} позиций. Маржинальный доход показан как расчётный. + +
+ ) : null}
)) )}
-
-
-

Продажи по каналам

+
+
+
+

Продажи по каналам

+ + {metrics.by_channel.length} + +
{metrics.by_channel.length === 0 ? (

Разбивки по каналам пока нет.

) : ( -
- - - - - - - - - - - - - {metrics.by_channel.map((channel) => ( - - - - - - - - - ))} - -
КаналКоличествоВыручкаСебестоимостьРасходыМаржа
{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)}
+
+ {metrics.by_channel.map((channel) => { + const channelKey = `${channel.channel}:${channel.currency}`; + return ( + setExpandedChannelKey((current) => current === channelKey ? null : channelKey)} + /> + ); + })}
)}
-
+
- +

Остатки по источникам

{metrics.stock.length === 0 ? ( @@ -259,7 +383,7 @@ export default function ProductDetailPage() { )}
-
+

Внешние соответствия

@@ -295,6 +419,17 @@ export default function ProductDetailPage() {
+ {previewImage ? ( + setPreviewImage(null)} + /> + ) : null} ); } @@ -303,17 +438,151 @@ function Metric({ label, value, accent = false, + wide = false, }: { label: string; value: string; accent?: boolean; + wide?: boolean; }) { return ( -
+

{label}

-

+

{value}

); } + +function ProductChannelCard({ + channel, + expanded, + onToggle, +}: { + channel: ProductChannelMetrics; + expanded: boolean; + onToggle: () => void; +}) { + const detailsId = `product-channel-${channel.channel}-${channel.currency}`.replace( + /[^a-zA-Z0-9_-]/g, + "-", + ); + const costBreakdown = Object.entries(channel.cost_breakdown); + const marginIsPositive = Number(channel.contribution_margin) >= 0; + + return ( +
+
+ + + +
+ + {expanded ? ( +
+
+ + + +
+ + {costBreakdown.length > 0 ? ( +
+

+ Структура дополнительных расходов +

+
+ {costBreakdown.map(([costType, value]) => ( +
+ + {costTypeLabels[costType] || costType} + + + {formatMoney(value, channel.currency)} + +
+ ))} +
+
+ ) : null} + {!channel.cost_complete ? ( +
+ + + Нет себестоимости для {channel.missing_cost_lines} позиций; маржа расчётная. + +
+ ) : null} +
+ ) : null} +
+ ); +} + +function ChannelDetail({ + label, + value, + valueClassName = "text-[var(--foreground)]", +}: { + label: string; + value: string; + valueClassName?: string; +}) { + return ( +
+ {label} + + {value} + +
+ ); +} diff --git a/frontend/src/app/products/connections/page.tsx b/frontend/src/app/products/connections/page.tsx index c85b4803..8e1dad63 100644 --- a/frontend/src/app/products/connections/page.tsx +++ b/frontend/src/app/products/connections/page.tsx @@ -1,297 +1,12 @@ "use client"; import { AppShell } from "@/components/AppShell"; -import { useUser } from "@/contexts/UserContext"; -import { apiClient } from "@/lib/api"; -import type { - IntegrationConnection, - IntegrationConnectionInput, -} from "@/types/commerce"; -import { - ArrowLeft, - CheckCircle2, - CloudDownload, - Clock3, - KeyRound, - Loader2, - PackageCheck, - RefreshCw, - ShieldCheck, - Store, - Trash2, - TriangleAlert, -} from "lucide-react"; -import Link from "next/link"; -import { useCallback, useEffect, useMemo, useState } from "react"; - -const statusLabels: Record = { - disconnected: "Нужна проверка", - ready: "Подключено", - queued: "В очереди", - syncing: "Импорт выполняется", - error: "Ошибка", -}; - -function displayDate(value?: string | null) { - if (!value) return "—"; - return new Intl.DateTimeFormat("ru-RU", { - dateStyle: "short", - timeStyle: "short", - }).format(new Date(value)); -} +import { ProductConnectionsContent } from "@/components/commerce/ProductConnectionsContent"; export default function ProductConnectionsPage() { - const { user, loading: userLoading } = useUser(); - const [connections, setConnections] = useState([]); - const [loading, setLoading] = useState(true); - const [busy, setBusy] = useState(null); - const [error, setError] = useState(null); - const [editing, setEditing] = useState(false); - const [authType, setAuthType] = useState<"token" | "basic">("token"); - const [token, setToken] = useState(""); - const [login, setLogin] = useState(""); - const [password, setPassword] = useState(""); - - const permissions = user?.auth?.permissions_by_app?.commerce ?? []; - const canManage = Boolean( - user?.auth?.is_staff || - user?.auth?.is_superuser || - permissions.some( - (permission) => - permission.startsWith("add_") || permission.startsWith("change_"), - ), - ); - - const moysklad = useMemo( - () => connections.find((connection) => connection.provider === "moysklad"), - [connections], - ); - - const loadConnections = useCallback(async (silent = false) => { - if (!silent) setLoading(true); - try { - const response = await apiClient.getCommerceConnections(); - setConnections(response.results); - setError(null); - } catch (requestError) { - setError( - requestError instanceof Error - ? requestError.message - : "Не удалось загрузить подключения.", - ); - } finally { - if (!silent) setLoading(false); - } - }, []); - - useEffect(() => { - if (!canManage) return; - const timer = window.setTimeout(() => void loadConnections(), 0); - return () => window.clearTimeout(timer); - }, [canManage, loadConnections]); - - useEffect(() => { - const active = connections.some((connection) => - ["queued", "syncing"].includes(connection.status), - ); - if (!active) return; - const timer = window.setTimeout(() => void loadConnections(true), 2500); - return () => window.clearTimeout(timer); - }, [connections, loadConnections]); - - async function connectAndImport(event: React.FormEvent) { - event.preventDefault(); - setBusy("connect"); - setError(null); - const payload: IntegrationConnectionInput = { - provider: "moysklad", - auth_type: authType, - ...(authType === "token" ? { token } : { login, password }), - }; - try { - const connection = moysklad - ? await apiClient.updateCommerceConnection(moysklad.id, payload) - : await apiClient.createCommerceConnection(payload); - await apiClient.testCommerceConnection(connection.id); - await apiClient.syncCommerceConnection(connection.id); - setToken(""); - setPassword(""); - setEditing(false); - await loadConnections(true); - } catch (requestError) { - setError( - requestError instanceof Error - ? requestError.message - : "Не удалось подключить МойСклад.", - ); - await loadConnections(true); - } finally { - setBusy(null); - } - } - - async function startSync(connection: IntegrationConnection) { - setBusy(connection.id); - setError(null); - try { - await apiClient.syncCommerceConnection(connection.id); - await loadConnections(true); - } catch (requestError) { - setError( - requestError instanceof Error - ? requestError.message - : "Не удалось запустить импорт.", - ); - } finally { - setBusy(null); - } - } - - async function updateSchedule( - connection: IntegrationConnection, - changes: Pick< - IntegrationConnectionInput, - "auto_sync" | "sync_interval_minutes" - >, - ) { - setBusy(connection.id); - setError(null); - try { - const updated = await apiClient.updateCommerceConnection( - connection.id, - changes, - ); - setConnections((current) => - current.map((item) => (item.id === updated.id ? updated : item)), - ); - } catch (requestError) { - setError( - requestError instanceof Error - ? requestError.message - : "Не удалось изменить расписание.", - ); - } finally { - setBusy(null); - } - } - - async function disconnect(connection: IntegrationConnection) { - if (!window.confirm("Отключить сервис? Уже импортированные товары сохранятся.")) { - return; - } - setBusy(connection.id); - try { - await apiClient.deleteCommerceConnection(connection.id); - setConnections((current) => current.filter((item) => item.id !== connection.id)); - setEditing(false); - } catch (requestError) { - setError( - requestError instanceof Error - ? requestError.message - : "Не удалось отключить сервис.", - ); - } finally { - setBusy(null); - } - } - - if (userLoading) { - return
Проверка прав…
; - } - - if (!canManage) { - return
У вас нет прав на подключение сервисов.
; - } - - const syncActive = moysklad && ["queued", "syncing"].includes(moysklad.status); - return ( -
-
-
- К товарам -

Подключения

-
- -
- - {error ?
{error}
: null} - -
-
-
-
-
-

МойСклад

-

Массовый импорт товаров, модификаций и текущих остатков с сохранением внешних идентификаторов.

-
-
- {moysklad ? {syncActive ? : null}{statusLabels[moysklad.status]} : null} -
- - {loading ?
Загрузка…
: moysklad && !editing ? ( -
-
- - - - - -
- {moysklad.last_error ?
{moysklad.last_error}
: null} -
-
Учётная запись
{moysklad.account_name || "—"}
-
Последняя проверка
{displayDate(moysklad.last_tested_at)}
-
Последний импорт
{displayDate(moysklad.last_sync_finished_at)}
-
-
- -
- - Следующий запуск: {moysklad.auto_sync ? displayDate(moysklad.next_sync_at) : "отключён"} -
-
-
- - - -
- {moysklad.recent_runs.length ?

Последние запуски

{moysklad.recent_runs.map((run) =>
{displayDate(run.created_at)} · {run.status === "success" ? "завершено" : run.status === "error" ? "ошибка" : "выполняется"}{run.processed} позиций
)}
: null} -
- ) : ( -
-
Реквизиты шифруются на сервере и никогда не возвращаются в браузер.
-
- - -
- {authType === "token" ? :
} -
- - {moysklad ? : null} -
- - )} -
- -
- - -
-
+
); } - -function Field({ label, value, onChange, type = "text", placeholder }: { label: string; value: string; onChange: (value: string) => void; type?: string; placeholder?: string }) { - return ; -} - -function Metric({ icon: Icon, label, value }: { icon: typeof Store; label: string; value: number }) { - return

{new Intl.NumberFormat("ru-RU").format(value)}

{label}

; -} - -function PlannedService({ name, description }: { name: string; description: string }) { - return

{name}

{description}

Следующий этап
; -} diff --git a/frontend/src/app/products/manage/page.tsx b/frontend/src/app/products/manage/page.tsx index 4e138908..de382f7e 100644 --- a/frontend/src/app/products/manage/page.tsx +++ b/frontend/src/app/products/manage/page.tsx @@ -1,6 +1,7 @@ "use client"; import { AppShell } from "@/components/AppShell"; +import { ProductConnectionsContent } from "@/components/commerce/ProductConnectionsContent"; import { Modal } from "@/components/ui"; import { useUser } from "@/contexts/UserContext"; import { apiClient } from "@/lib/api"; @@ -16,14 +17,18 @@ import type { import { ArrowLeft, Boxes, + ChevronLeft, + ChevronRight, CircleDollarSign, Database, Link2, ListTree, Pencil, + PlugZap, Plus, ReceiptText, RefreshCw, + Search, Trash2, } from "lucide-react"; import Link from "next/link"; @@ -35,12 +40,15 @@ import { type ReactNode, } from "react"; -type Tab = "products" | "sources" | "bindings" | "sales" | "costs" | "stock"; +type Tab = "connections" | "products" | "sources" | "bindings" | "sales" | "costs" | "stock"; type EditorKind = "product" | "source" | "binding" | "sale" | "line" | "cost" | "stock"; type Editor = { kind: EditorKind; id?: string | number } | null; type FormState = Record; +const PAGE_SIZE = 50; + const tabs: Array<{ id: Tab; label: string; icon: typeof Boxes }> = [ + { id: "connections", label: "Подключения", icon: PlugZap }, { id: "products", label: "Товары", icon: Boxes }, { id: "sources", label: "Источники", icon: Database }, { id: "bindings", label: "Привязки", icon: Link2 }, @@ -59,6 +67,12 @@ const costTypeLabels: Record = { other: "Прочее", }; +const productKindLabels: Record = { + product: "Товар", + service: "Услуга", + bundle: "Комплект", +}; + function localDateTime(value?: string | null) { const date = value ? new Date(value) : new Date(); const offset = date.getTimezoneOffset() * 60_000; @@ -96,7 +110,13 @@ export default function CommerceManagementPage() { const [form, setForm] = useState({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [connectionsRefreshKey, setConnectionsRefreshKey] = useState(0); const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + const [page, setPage] = useState(1); + const [listCount, setListCount] = useState(0); + const [hasNextPage, setHasNextPage] = useState(false); + const [hasPreviousPage, setHasPreviousPage] = useState(false); const permissions = user?.auth?.permissions_by_app?.commerce ?? []; const canManage = Boolean( @@ -111,32 +131,110 @@ export default function CommerceManagementPage() { ); const loadData = useCallback(async () => { + if (tab === "connections") { + setLoading(false); + return; + } + setLoading(true); setError(null); + const query = search.trim() || undefined; + const setPagination = (response: { + count: number; + next: string | null; + previous: string | null; + }) => { + setListCount(response.count); + setHasNextPage(Boolean(response.next)); + setHasPreviousPage(Boolean(response.previous)); + }; + try { - const [productData, sourceData, bindingData, saleData, costData, stockData] = - await Promise.all([ + if (tab === "products") { + const response = await apiClient.getCommerceProducts({ + search: query, + page, + page_size: PAGE_SIZE, + ordering: "name", + }); + setProducts(response.results); + setPagination(response); + } else if (tab === "sources") { + const response = await apiClient.getCommerceSources({ + search: query || "", + page, + page_size: PAGE_SIZE, + }); + setSources(response.results); + setPagination(response); + } else { + const referenceRequests = Promise.all([ apiClient.getCommerceProducts({ page_size: 200, ordering: "name" }), apiClient.getCommerceSources(), - apiClient.getCommerceBindings({ page_size: 200 }), - apiClient.getCommerceSales({ page_size: 200, ordering: "-occurred_at" }), - apiClient.getCommerceCosts({ page_size: 200, ordering: "-occurred_at" }), - apiClient.getCommerceStockSnapshots({ - page_size: 200, - ordering: "-captured_at", - }), ]); - setProducts(productData.results); - setSources(sourceData.results); - setBindings(bindingData.results); - setSales(saleData.results); - setCosts(costData.results); - setStock(stockData.results); - setSelectedSaleId((current) => - current && saleData.results.some((sale) => sale.id === current) - ? current - : null, - ); + + if (tab === "bindings") { + const [response, [productData, sourceData]] = await Promise.all([ + apiClient.getCommerceBindings({ + search: query || "", + page, + page_size: PAGE_SIZE, + }), + referenceRequests, + ]); + setBindings(response.results); + setProducts(productData.results); + setSources(sourceData.results); + setPagination(response); + } else if (tab === "sales") { + const [response, [productData, sourceData]] = await Promise.all([ + apiClient.getCommerceSales({ + search: query || "", + page, + page_size: PAGE_SIZE, + ordering: "-occurred_at", + }), + referenceRequests, + ]); + setSales(response.results); + setProducts(productData.results); + setSources(sourceData.results); + setPagination(response); + setSelectedSaleId((current) => + current && response.results.some((sale) => sale.id === current) + ? current + : null, + ); + } else if (tab === "costs") { + const [response, [productData, sourceData]] = await Promise.all([ + apiClient.getCommerceCosts({ + search: query || "", + page, + page_size: PAGE_SIZE, + ordering: "-occurred_at", + }), + referenceRequests, + ]); + setCosts(response.results); + setProducts(productData.results); + setSources(sourceData.results); + setPagination(response); + } else { + const [response, [productData, sourceData]] = await Promise.all([ + apiClient.getCommerceStockSnapshots({ + search: query || "", + page, + page_size: PAGE_SIZE, + ordering: "-captured_at", + }), + referenceRequests, + ]); + setStock(response.results); + setProducts(productData.results); + setSources(sourceData.results); + setPagination(response); + } + } } catch (requestError) { setError( requestError instanceof Error @@ -146,11 +244,11 @@ export default function CommerceManagementPage() { } finally { setLoading(false); } - }, []); + }, [page, search, tab]); useEffect(() => { if (!canManage) return; - const refreshTimer = window.setTimeout(() => void loadData(), 0); + const refreshTimer = window.setTimeout(() => void loadData(), 250); return () => window.clearTimeout(refreshTimer); }, [canManage, loadData]); @@ -164,6 +262,23 @@ export default function CommerceManagementPage() { ); const selectedSale = sales.find((sale) => sale.id === selectedSaleId) ?? null; + const searchPlaceholders: Partial> = { + products: "Название, артикул или штрихкод", + sources: "Название или код источника", + bindings: "Товар, внешний ID или SKU", + sales: "Номер продажи, товар или SKU", + costs: "Товар, канал или внешний ID", + stock: "Товар, склад или артикул", + }; + + function selectTab(nextTab: Tab) { + setTab(nextTab); + setSearch(""); + setPage(1); + setListCount(0); + setSelectedSaleId(null); + } + function openCreate(kind: EditorKind) { const now = localDateTime(); const defaults: Record = { @@ -287,27 +402,61 @@ export default function CommerceManagementPage() { return ( -
-
-
- К каталогу -

Управление товарами и аналитикой

-

Ручное управление справочниками и нормализованными фактами.

-
- -
+
+ К каталогу + +
+
+
+
+

Управление товарами

+ {tab !== "connections" && !loading ? ( + + {listCount} + + ) : null} +
+

Каталог, подключения, продажи и складские данные.

+
+ +
+ + + + {tab !== "connections" ? ( + + ) : null} +
{error ?
{error}
: null} - - {loading ?
Загрузка…
: ( <> + {tab === "connections" ? : null} {tab === "products" ? openCreate("product")} onEdit={(item) => openEdit("product", item)} onDelete={(id) => void remove("product", id)} /> : null} {tab === "sources" ? openCreate("source")} onEdit={(item) => openEdit("source", item)} onDelete={(id) => void remove("source", id)} /> : null} {tab === "bindings" ? openCreate("binding")} onEdit={(item) => openEdit("binding", item)} onDelete={(id) => void remove("binding", id)} /> : null} @@ -317,6 +466,19 @@ export default function CommerceManagementPage() { {tab === "stock" ? openCreate("stock")} onEdit={(item) => openEdit("stock", item)} onDelete={(id) => void remove("stock", id)} /> : null} )} + + {tab !== "connections" && !loading && listCount > 0 ? ( +
+

+ {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, listCount)} из {listCount} +

+
+ + {page} + +
+
+ ) : null}
setEditor(null)} title={editorTitle(editor)} size="lg" footer={
}> @@ -407,13 +569,13 @@ function EditorFields({ kind, editorId, form, setForm, products, sources, sales type TableActions = { onCreate: () => void; onEdit: (item: TItem) => void; onDelete: (id: TId) => void }; function Section({ title, subtitle, onCreate, children }: { title: string; subtitle: string; onCreate: () => void; children: ReactNode }) { - return

{title}

{subtitle}

{children}
; + return

{title}

{subtitle}

{children}
; } -function Actions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) { return
; } +function Actions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) { return
; } function EmptyRow({ columns }: { columns: number }) { return Записей пока нет.; } -function ProductsTable({ items, onCreate, onEdit, onDelete }: { items: CommerceProduct[] } & TableActions) { return
{items.length ? items.map((item) => ) : }
{item.sku}{item.name}{item.kind}{item.bindings.length} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } +function ProductsTable({ items, onCreate, onEdit, onDelete }: { items: CommerceProduct[] } & TableActions) { return
{items.length ? items.map((item) => ) : }
{item.sku}{item.name}{productKindLabels[item.kind]}{item.bindings.length} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } function SourcesTable({ items, onCreate, onEdit, onDelete }: { items: SourceSystem[] } & TableActions) { return
{items.length ? items.map((item) => ) : }
{item.code}{item.name}{item.is_active ? "Активен" : "Отключён"} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } function BindingsTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: ProductBinding[]; productMap: Map; sourceMap: Map } & TableActions) { return
{items.length ? items.map((item) => ) : }
{productMap.get(item.product)?.sku ?? item.product}{sourceMap.get(item.source)?.name ?? item.source_code}{item.external_id}{item.external_sku || "—"} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } function SalesTable({ items, sourceMap, selectedSaleId, onSelect, onCreate, onEdit, onDelete }: { items: CommerceSale[]; sourceMap: Map; selectedSaleId: string | null; onSelect: (id: string) => void } & TableActions) { return
{items.length ? items.map((item) => ) : }
{sourceMap.get(item.source)?.name ?? item.source_code}{item.channel || "—"}{item.status || item.event_type}{displayDate(item.occurred_at)}{item.lines.length} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } @@ -421,8 +583,8 @@ function SaleLinesTable({ sale, productMap, onCreate, onEdit, onDelete }: { sale function CostsTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: CommerceCost[]; productMap: Map; sourceMap: Map } & TableActions) { return
{items.length ? items.map((item) => ) : }
{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)} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } function StockTable({ items, productMap, sourceMap, onCreate, onEdit, onDelete }: { items: CommerceStockSnapshot[]; productMap: Map; sourceMap: Map } & TableActions) { return
{items.length ? items.map((item) => ) : }
{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)} onEdit(item)} onDelete={() => onDelete(item.id)} />
; } -function Table({ headers, children }: { headers: string[]; children: ReactNode }) { return
{headers.map((header, index) => )}{children}
{header}
; } -function Td({ children }: { children: ReactNode }) { return {children}; } +function Table({ headers, children }: { headers: string[]; children: ReactNode }) { return
{headers.map((header, index) => )}{children}
{header}
; } +function Td({ children }: { children: ReactNode }) { return {children}; } function Field({ label, value, onChange, required = false, type = "text", step, placeholder, multiline = false, className = "" }: { label: string; value: string; onChange: (value: string) => void; required?: boolean; type?: string; step?: string; placeholder?: string; multiline?: boolean; className?: string }) { const controlClass = "mt-1 w-full rounded-lg border border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-2 text-sm outline-none focus:border-[var(--border-strong)]"; return