2026-08-18 09:49:09 +03:00

318 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
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
ASSORTMENT_ENTITY_TYPES = ("product", "variant", "bundle", "service")
def __init__(self, *, auth_type: str, credentials: dict[str, str]):
self.base_url = (
getattr(settings, "MOYSKLAD_API_BASE_URL", self.DEFAULT_BASE_URL).rstrip(
"/"
)
+ "/"
)
headers = {
"Accept": "application/json;charset=utf-8",
"Accept-Encoding": "gzip",
"User-Agent": "EUSRR-Commerce/1.0",
}
auth = None
if auth_type == "token":
token = re.sub(
r"^Bearer\s+",
"",
credentials.get("token", "").strip(),
flags=re.IGNORECASE,
).strip()
if not token:
raise MoySkladError("Не указан токен МойСклад.")
headers["Authorization"] = f"Bearer {token}"
elif auth_type == "basic":
login = credentials.get("login", "").strip()
password = credentials.get("password", "")
if not login or not password:
raise MoySkladError("Не указаны логин или пароль МойСклад.")
auth = httpx.BasicAuth(login, password)
else:
raise MoySkladError("Неподдерживаемый способ авторизации.")
self.client = httpx.Client(
base_url=self.base_url,
headers=headers,
auth=auth,
timeout=httpx.Timeout(30.0),
follow_redirects=False,
)
def close(self):
self.client.close()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
self.close()
def _get(self, path: str, *, params: dict[str, Any] | None = None) -> dict:
try:
response = self.client.get(path.lstrip("/"), params=params)
response.raise_for_status()
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(
"Не удалось получить корректный ответ от МойСклад."
) from exc
if not isinstance(payload, dict):
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 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_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": page_size,
"offset": offset,
"stockMode": "all",
"expand": "images",
},
)
rows = payload.get("rows", [])
if not isinstance(rows, list):
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) < 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"}:
raise MoySkladError("Неподдерживаемый тип документа МойСклад.")
offset = 0
while True:
payload = self._get(
f"/entity/{entity_type}",
params={
"limit": limit,
"offset": offset,
"expand": "positions.assortment,state,salesChannel,rate.currency",
},
)
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) < 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