Import archived MoySklad assortment

This commit is contained in:
lizerk 2026-08-18 09:49:09 +03:00
parent 5b22e0a2c8
commit 5a22d9b2a3
3 changed files with 169 additions and 7 deletions

View File

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

View File

@ -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}, "

View File

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