Import archived MoySklad assortment
This commit is contained in:
parent
5b22e0a2c8
commit
5a22d9b2a3
@ -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"}:
|
||||
|
||||
@ -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}, "
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user