From 5a22d9b2a345251acb7ee20dbbba3c6982cd0b59 Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 18 Aug 2026 09:49:09 +0300 Subject: [PATCH] Import archived MoySklad assortment --- backend/commerce/integrations/moysklad.py | 51 +++++++++++++-- .../commands/repair_moysklad_data.py | 61 +++++++++++++++++- backend/tests/commerce/test_connections.py | 64 ++++++++++++++++++- 3 files changed, 169 insertions(+), 7 deletions(-) diff --git a/backend/commerce/integrations/moysklad.py b/backend/commerce/integrations/moysklad.py index 0c067891..25cd6cd0 100644 --- a/backend/commerce/integrations/moysklad.py +++ b/backend/commerce/integrations/moysklad.py @@ -25,6 +25,7 @@ class MoySkladRemoteImage: class MoySkladClient: DEFAULT_BASE_URL = "https://api.moysklad.ru/api/remap/1.2" MAX_IMAGE_BYTES = 25 * 1024 * 1024 + ASSORTMENT_ENTITY_TYPES = ("product", "variant", "bundle", "service") def __init__(self, *, auth_type: str, credentials: dict[str, str]): self.base_url = ( @@ -212,13 +213,43 @@ class MoySkladClient: if isinstance(row, dict) and row.get("id") } - def iter_assortment(self, *, limit: int = 100) -> Iterator[dict]: + def iter_archived_assortment(self, *, limit: int = 100) -> Iterator[dict]: + page_size = min(limit, 100) + for entity_type in self.ASSORTMENT_ENTITY_TYPES: + offset = 0 + while True: + payload = self._get( + f"/entity/{entity_type}", + params={ + "limit": page_size, + "offset": offset, + "filter": "archived=true", + "expand": "images", + }, + ) + 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: + break + offset += page_size + + def iter_assortment( + self, *, limit: int = 100, include_archived: bool = True + ) -> Iterator[dict]: offset = 0 + seen_ids: set[str] = set() + page_size = min(limit, 100) while True: payload = self._get( "/entity/assortment", params={ - "limit": min(limit, 100), + "limit": page_size, "offset": offset, "stockMode": "all", "expand": "images", @@ -229,10 +260,20 @@ class MoySkladClient: raise MoySkladError("В ответе ассортимента отсутствует список rows.") for row in rows: if isinstance(row, dict): + external_id = str(row.get("id") or "") + if external_id: + seen_ids.add(external_id) yield row - if len(rows) < min(limit, 100): - return - offset += min(limit, 100) + if len(rows) < page_size: + break + offset += page_size + + if include_archived: + for row in self.iter_archived_assortment(limit=page_size): + external_id = str(row.get("id") or "") + if external_id and external_id in seen_ids: + continue + yield row def iter_documents(self, entity_type: str, *, limit: int = 100) -> Iterator[dict]: if entity_type not in {"demand", "salesreturn"}: diff --git a/backend/commerce/management/commands/repair_moysklad_data.py b/backend/commerce/management/commands/repair_moysklad_data.py index aa31a1cf..9000c5f1 100644 --- a/backend/commerce/management/commands/repair_moysklad_data.py +++ b/backend/commerce/management/commands/repair_moysklad_data.py @@ -1,7 +1,8 @@ from datetime import timedelta +import time from django.core.management.base import BaseCommand, CommandError -from django.db import transaction +from django.db import OperationalError, close_old_connections, transaction from django.utils import timezone from commerce.integrations.moysklad import MoySkladClient, MoySkladError @@ -19,6 +20,7 @@ from commerce.models import ( from commerce.services.credentials import CredentialError, decrypt_credentials from commerce.services.moysklad_sync import ( _document_channel, + normalize_moysklad_assortment_row, normalize_moysklad_stock_row, reconcile_moysklad_variant_parents, ) @@ -27,6 +29,25 @@ from commerce.services.moysklad_sync import ( class Command(BaseCommand): help = "Восстанавливает нормализованные данные МойСклад из SourceRecord и API." + def add_arguments(self, parser): + parser.add_argument( + "--missing-archive-only", + action="store_true", + help="Импортировать только ещё отсутствующие архивные позиции.", + ) + + @staticmethod + def _retry_database_lock(callback): + for attempt in range(1, 7): + try: + return callback() + except OperationalError as exc: + if "database is locked" not in str(exc).lower() or attempt == 6: + raise + close_old_connections() + time.sleep(0.25 * attempt) + raise RuntimeError("Недостижимая ветка повтора блокировки базы.") + def handle(self, *args, **options): # noqa: C901 - orchestration command try: connection = IntegrationConnection.objects.select_related("source").get( @@ -79,6 +100,9 @@ class Command(BaseCommand): repaired_images = 0 image_errors = 0 + archived_processed = 0 + archived_created = 0 + archived_skipped = 0 stock_snapshots = 0 updated_channels = 0 captured_at = timezone.now() @@ -88,6 +112,38 @@ class Command(BaseCommand): credentials=credentials, ) as client: channel_names = client.sales_channel_names() + for row in client.iter_archived_assortment(): + external_id = str(row.get("id") or "") + if ( + options["missing_archive_only"] + and external_id + and ProductBinding.objects.filter( + source=source, + external_id=external_id, + ).exists() + ): + archived_skipped += 1 + continue + created, _matched, _has_stock = self._retry_database_lock( + lambda: normalize_moysklad_assortment_row( + connection=connection, + row=row, + captured_at=captured_at, + ) + ) + archived_processed += 1 + archived_created += int(created) + try: + repaired_images += self._retry_database_lock( + lambda: sync_moysklad_product_images( + connection=connection, + row=row, + client=client, + ) + ) + except Exception: # noqa: BLE001 - continue repairing archive + image_errors += 1 + for record in SourceRecord.objects.filter( source=source, entity_type__in=["demand", "salesreturn"], @@ -148,6 +204,9 @@ class Command(BaseCommand): self.stdout.write( self.style.SUCCESS( "Восстановление завершено: " + f"архивных позиций={archived_processed}, " + f"создано архивных товаров={archived_created}, " + f"пропущено уже импортированных={archived_skipped}, " f"каналы={updated_channels}, " f"изображения={repaired_images}, " f"ошибки изображений={image_errors}, " diff --git a/backend/tests/commerce/test_connections.py b/backend/tests/commerce/test_connections.py index 8094e82d..62f42581 100644 --- a/backend/tests/commerce/test_connections.py +++ b/backend/tests/commerce/test_connections.py @@ -169,7 +169,69 @@ def test_assortment_expands_images_in_supported_page_size(settings): client.close() assert queries == [ - {"limit": "100", "offset": "0", "stockMode": "all", "expand": "images"} + {"limit": "100", "offset": "0", "stockMode": "all", "expand": "images"}, + { + "limit": "100", + "offset": "0", + "filter": "archived=true", + "expand": "images", + }, + { + "limit": "100", + "offset": "0", + "filter": "archived=true", + "expand": "images", + }, + { + "limit": "100", + "offset": "0", + "filter": "archived=true", + "expand": "images", + }, + { + "limit": "100", + "offset": "0", + "filter": "archived=true", + "expand": "images", + }, + ] + + +def test_assortment_includes_archived_entity_endpoints(settings): + settings.MOYSKLAD_API_BASE_URL = "https://api.example/api/remap/1.2" + + def handler(request): + entity_type = request.url.path.rstrip("/").rsplit("/", 1)[-1] + if entity_type == "assortment": + rows = [{"id": "active", "name": "Активный", "archived": False}] + else: + rows = [ + { + "id": f"archived-{entity_type}", + "name": f"Архивный {entity_type}", + "archived": True, + "meta": {"type": entity_type}, + } + ] + return httpx.Response(200, json={"rows": 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: + rows = list(client.iter_assortment()) + finally: + client.close() + + assert [row["id"] for row in rows] == [ + "active", + "archived-product", + "archived-variant", + "archived-bundle", + "archived-service", ]