828 lines
32 KiB
Python
828 lines
32 KiB
Python
"""
|
||
Клиент для работы с API МойСклад
|
||
Документация: https://dev.moysklad.ru/
|
||
"""
|
||
import requests
|
||
from base64 import b64encode
|
||
from typing import Dict, List, Optional, Any
|
||
from datetime import datetime
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class MoySkladAPIError(Exception):
|
||
"""Исключение для ошибок API МойСклад"""
|
||
pass
|
||
|
||
|
||
class MoySkladClient:
|
||
"""Клиент для работы с API МойСклад"""
|
||
|
||
def __init__(self, login: str, password: str, api_url: str):
|
||
"""
|
||
Инициализация клиента
|
||
|
||
Args:
|
||
login: Логин пользователя МойСклад
|
||
password: Пароль пользователя МойСклад
|
||
api_url: URL API (https://api.moysklad.ru/api/remap/1.2)
|
||
"""
|
||
self.api_url = api_url.rstrip('/')
|
||
|
||
# Формирование заголовков с Basic Auth
|
||
credentials = f"{login}:{password}"
|
||
encoded = b64encode(credentials.encode()).decode()
|
||
|
||
self.headers = {
|
||
"Authorization": f"Basic {encoded}",
|
||
"Accept-Encoding": "gzip",
|
||
"Content-Type": "application/json"
|
||
}
|
||
|
||
logger.info("Клиент МойСклад инициализирован")
|
||
|
||
@staticmethod
|
||
def _extract_id(value: str) -> str:
|
||
"""Extract an entity UUID from a MoySklad href or raw id."""
|
||
if value.startswith("http"):
|
||
value = value.split("/")[-1]
|
||
if "?" in value:
|
||
value = value.split("?")[0]
|
||
return value
|
||
|
||
def _make_request(
|
||
self,
|
||
method: str,
|
||
endpoint: str,
|
||
params: Optional[Dict] = None,
|
||
json_data: Optional[Dict] = None,
|
||
return_raw: bool = False
|
||
) -> Dict:
|
||
"""
|
||
Выполнение HTTP запроса к API
|
||
|
||
Args:
|
||
method: HTTP метод (GET, POST, PUT, DELETE)
|
||
endpoint: Конечная точка API
|
||
params: Query параметры
|
||
json_data: JSON данные для отправки
|
||
return_raw: Если True, возвращает Response объект вместо JSON
|
||
|
||
Returns:
|
||
Ответ API в виде словаря или Response если return_raw=True
|
||
|
||
Raises:
|
||
MoySkladAPIError: При ошибке запроса
|
||
"""
|
||
url = f"{self.api_url}/{endpoint.lstrip('/')}"
|
||
|
||
try:
|
||
response = requests.request(
|
||
method=method,
|
||
url=url,
|
||
headers=self.headers,
|
||
params=params,
|
||
json=json_data,
|
||
timeout=30
|
||
)
|
||
|
||
if return_raw:
|
||
return response
|
||
|
||
# Проверка статус кода
|
||
if response.status_code >= 400:
|
||
error_msg = f"API Error {response.status_code}: {response.text}"
|
||
logger.error(error_msg)
|
||
raise MoySkladAPIError(error_msg)
|
||
|
||
return response.json()
|
||
|
||
except requests.exceptions.RequestException as e:
|
||
error_msg = f"Ошибка запроса к API: {str(e)}"
|
||
logger.error(error_msg)
|
||
raise MoySkladAPIError(error_msg)
|
||
|
||
def get_orders(
|
||
self,
|
||
limit: int = 25,
|
||
offset: int = 0,
|
||
expand: Optional[List[str]] = None
|
||
) -> Dict:
|
||
"""
|
||
Получение списка отгрузок (demands)
|
||
|
||
Args:
|
||
limit: Количество отгрузок для получения (макс 100)
|
||
offset: Смещение для пагинации
|
||
expand: Список связанных сущностей для раскрытия
|
||
|
||
Returns:
|
||
Словарь с данными отгрузок
|
||
"""
|
||
params = {
|
||
"limit": min(limit, 100),
|
||
"offset": offset
|
||
}
|
||
|
||
# Раскрытие связанных сущностей
|
||
if expand is None:
|
||
expand = ["agent", "organization", "positions.assortment",
|
||
"rate.currency"]
|
||
|
||
if expand:
|
||
params["expand"] = ",".join(expand)
|
||
|
||
logger.info(f"Получение отгрузок: limit={limit}, offset={offset}")
|
||
return self._make_request("GET", "entity/demand", params=params)
|
||
|
||
def get_order_by_id(self, order_id: str) -> Dict:
|
||
"""
|
||
Получение детальной информации об отгрузке
|
||
|
||
Args:
|
||
order_id: ID отгрузки (UUID или href)
|
||
|
||
Returns:
|
||
Данные отгрузки
|
||
"""
|
||
# Извлечение UUID из href если передан полный URL
|
||
if order_id.startswith("http"):
|
||
order_id = order_id.split("/")[-1]
|
||
|
||
# Удаление query параметров если есть
|
||
if "?" in order_id:
|
||
order_id = order_id.split("?")[0]
|
||
|
||
expand = (
|
||
"agent,organization,consignee,"
|
||
"positions.assortment,positions,rate.currency"
|
||
)
|
||
endpoint = f"entity/demand/{order_id}"
|
||
|
||
logger.info(f"Получение отгрузки {order_id}")
|
||
return self._make_request(
|
||
"GET",
|
||
endpoint,
|
||
params={"expand": expand}
|
||
)
|
||
|
||
def get_organization(self, org_id: str) -> Dict:
|
||
"""
|
||
Получение информации об организации (продавец)
|
||
|
||
Args:
|
||
org_id: ID организации
|
||
|
||
Returns:
|
||
Данные организации
|
||
"""
|
||
if org_id.startswith("http"):
|
||
org_id = org_id.split("/")[-1]
|
||
|
||
# Удаление query параметров если есть
|
||
if "?" in org_id:
|
||
org_id = org_id.split("?")[0]
|
||
|
||
logger.info(f"Получение организации {org_id}")
|
||
return self._make_request("GET", f"entity/organization/{org_id}")
|
||
|
||
def get_counterparty(self, agent_id: str) -> Dict:
|
||
"""
|
||
Получение информации о контрагенте (покупатель)
|
||
|
||
Args:
|
||
agent_id: ID контрагента
|
||
|
||
Returns:
|
||
Данные контрагента
|
||
"""
|
||
if agent_id.startswith("http"):
|
||
agent_id = agent_id.split("/")[-1]
|
||
|
||
# Удаление query параметров если есть
|
||
if "?" in agent_id:
|
||
agent_id = agent_id.split("?")[0]
|
||
|
||
logger.info(f"Получение контрагента {agent_id}")
|
||
return self._make_request("GET", f"entity/counterparty/{agent_id}")
|
||
|
||
def get_agent(self, agent_meta: Dict) -> Dict:
|
||
"""Load an agent using its MoySklad entity type."""
|
||
meta = agent_meta.get("meta", agent_meta)
|
||
href = meta.get("href", "")
|
||
entity_type = meta.get("type", "counterparty")
|
||
agent_id = self._extract_id(href)
|
||
|
||
if entity_type == "organization":
|
||
return self.get_organization(agent_id)
|
||
if entity_type == "counterparty":
|
||
return self.get_counterparty(agent_id)
|
||
|
||
return self._make_request("GET", f"entity/{entity_type}/{agent_id}")
|
||
|
||
def get_product(self, product_id: str) -> Dict:
|
||
"""
|
||
Получение информации о товаре
|
||
|
||
Args:
|
||
product_id: ID товара
|
||
|
||
Returns:
|
||
Данные товара
|
||
"""
|
||
if product_id.startswith("http"):
|
||
product_id = product_id.split("/")[-1]
|
||
|
||
logger.info(f"Получение товара {product_id}")
|
||
return self._make_request("GET", f"entity/product/{product_id}")
|
||
|
||
def search_orders(
|
||
self,
|
||
search_term: str,
|
||
limit: int = 25
|
||
) -> Dict:
|
||
"""
|
||
Поиск отгрузок по номеру или названию.
|
||
Ищет по имени отгрузки, если не находит - ищет заказы
|
||
и получает связанные отгрузки.
|
||
|
||
Args:
|
||
search_term: Поисковый запрос (номер заказа или отгрузки)
|
||
limit: Количество результатов
|
||
|
||
Returns:
|
||
Результаты поиска
|
||
"""
|
||
# Сначала ищем по имени самой отгрузки
|
||
params = {
|
||
"filter": f"name~{search_term}",
|
||
"limit": min(limit, 100),
|
||
"expand": "agent,organization,customerOrder"
|
||
}
|
||
|
||
logger.info(
|
||
f"Поиск отгрузок по номеру: {search_term} (filter=name~)"
|
||
)
|
||
result = self._make_request("GET", "entity/demand", params=params)
|
||
|
||
# Если не нашли, ищем заказы покупателя с таким номером
|
||
if result.get("meta", {}).get("size", 0) == 0:
|
||
logger.info(
|
||
f"Отгрузки не найдены по имени, ищем заказы: {search_term}"
|
||
)
|
||
# Ищем заказы покупателя
|
||
orders_params = {
|
||
"filter": f"name~{search_term}",
|
||
"limit": 10
|
||
}
|
||
orders_result = self._make_request(
|
||
"GET",
|
||
"entity/customerorder",
|
||
params=orders_params
|
||
)
|
||
|
||
# Если нашли заказы, получаем их отгрузки
|
||
if orders_result.get("meta", {}).get("size", 0) > 0:
|
||
rows = orders_result.get("rows", [])
|
||
logger.info(
|
||
f"Найдено заказов: {len(rows)}"
|
||
)
|
||
|
||
# Получаем полную информацию о заказах с demands
|
||
all_demands = []
|
||
for order in rows[:5]: # Берем первые 5 заказов
|
||
order_id = order["id"]
|
||
order_name = order.get("name", "")
|
||
|
||
try:
|
||
# Получаем заказ с expand=demands
|
||
# demands - это массив отгрузок по заказу
|
||
order_detail = self._make_request(
|
||
"GET",
|
||
f"entity/customerorder/{order_id}",
|
||
params={"expand": "demands"}
|
||
)
|
||
|
||
# Получаем отгрузки из поля demands
|
||
demands = order_detail.get("demands", [])
|
||
logger.info(
|
||
f"Заказ {order_name}: "
|
||
f"найдено отгрузок {len(demands)}"
|
||
)
|
||
|
||
# Для каждой отгрузки получаем полные данные
|
||
for demand in demands:
|
||
demand_id = demand.get("id")
|
||
demand_name = demand.get("name", "")
|
||
|
||
if demand_id:
|
||
try:
|
||
# Получаем полные данные отгрузки
|
||
demand_detail = self._make_request(
|
||
"GET",
|
||
f"entity/demand/{demand_id}",
|
||
params={
|
||
"expand": "agent,organization,"
|
||
"customerOrder"
|
||
}
|
||
)
|
||
all_demands.append(demand_detail)
|
||
logger.info(
|
||
f"✓ Получена отгрузка {demand_name}"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"Не удалось получить отгрузку "
|
||
f"{demand_name}: {e}"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"Не удалось получить данные заказа "
|
||
f"{order_name}: {e}"
|
||
)
|
||
|
||
if all_demands:
|
||
result = {
|
||
"context": {
|
||
"employee": {
|
||
"meta": {
|
||
"href": "",
|
||
"type": "employee",
|
||
"mediaType": "application/json"
|
||
}
|
||
}
|
||
},
|
||
"meta": {
|
||
"href": "https://api.moysklad.ru/api/"
|
||
"remap/1.2/entity/demand",
|
||
"type": "demand",
|
||
"mediaType": "application/json",
|
||
"size": len(all_demands),
|
||
"limit": limit,
|
||
"offset": 0
|
||
},
|
||
"rows": all_demands[:limit]
|
||
}
|
||
|
||
return result
|
||
|
||
def get_order_positions(self, order_id: str) -> List[Dict]:
|
||
"""
|
||
Получение позиций отгрузки (товары)
|
||
|
||
Args:
|
||
order_id: ID отгрузки
|
||
|
||
Returns:
|
||
Список позиций отгрузки
|
||
"""
|
||
if order_id.startswith("http"):
|
||
order_id = order_id.split("/")[-1]
|
||
|
||
# Удаление query параметров если есть
|
||
if "?" in order_id:
|
||
order_id = order_id.split("?")[0]
|
||
|
||
endpoint = f"entity/demand/{order_id}/positions"
|
||
params = {"expand": "assortment,assortment.product.uom"}
|
||
|
||
logger.info(f"Получение позиций отгрузки {order_id}")
|
||
result = self._make_request("GET", endpoint, params=params)
|
||
|
||
return result.get("rows", [])
|
||
|
||
def get_accounts(self, entity_type: str, entity_id: str) -> list:
|
||
"""
|
||
Получение банковских счетов организации или контрагента
|
||
|
||
Args:
|
||
entity_type: Тип сущности (organization/counterparty)
|
||
entity_id: ID сущности
|
||
|
||
Returns:
|
||
Список банковских счетов
|
||
"""
|
||
entity_id = self._extract_id(entity_id)
|
||
endpoint = f"entity/{entity_type}/{entity_id}/accounts"
|
||
logger.info(f"Получение счетов {entity_type}/{entity_id}")
|
||
result = self._make_request("GET", endpoint)
|
||
return result.get("rows", [])
|
||
|
||
def get_full_order_data(self, order_id: str) -> Dict:
|
||
"""
|
||
Получение полных данных отгрузки со всеми связанными сущностями
|
||
|
||
Args:
|
||
order_id: ID отгрузки
|
||
|
||
Returns:
|
||
Полные данные отгрузки с организацией, контрагентом и позициями
|
||
"""
|
||
# Получаем основные данные отгрузки
|
||
order = self.get_order_by_id(order_id)
|
||
|
||
# Получаем дополнительные данные
|
||
result = {
|
||
"order": order,
|
||
"organization": None,
|
||
"counterparty": None,
|
||
"consignee": None,
|
||
"positions": [],
|
||
"org_accounts": [],
|
||
"cp_accounts": []
|
||
}
|
||
|
||
# Организация (продавец)
|
||
if "organization" in order:
|
||
org_meta = order["organization"]
|
||
if "meta" in org_meta:
|
||
org_id = self._extract_id(org_meta["meta"]["href"])
|
||
result["organization"] = self.get_organization(org_id)
|
||
# Получаем банковские счета организации
|
||
result["org_accounts"] = self.get_accounts(
|
||
"organization", org_id
|
||
)
|
||
|
||
# Контрагент (покупатель)
|
||
if "agent" in order:
|
||
agent_meta = order["agent"]
|
||
if "name" in agent_meta:
|
||
result["counterparty"] = agent_meta
|
||
if "meta" in agent_meta:
|
||
meta = agent_meta["meta"]
|
||
agent_id = self._extract_id(meta["href"])
|
||
agent_type = meta.get("type", "counterparty")
|
||
if result["counterparty"] is None:
|
||
result["counterparty"] = self.get_agent(agent_meta)
|
||
# Получаем банковские счета контрагента
|
||
try:
|
||
result["cp_accounts"] = self.get_accounts(
|
||
agent_type, agent_id
|
||
)
|
||
except MoySkladAPIError as e:
|
||
logger.warning(
|
||
"Could not load %s accounts %s: %s",
|
||
agent_type,
|
||
agent_id,
|
||
e
|
||
)
|
||
|
||
# Грузополучатель (если указан)
|
||
if "consignee" in order and order["consignee"]:
|
||
consignee_data = order["consignee"]
|
||
# Если уже раскрыт через expand - используем напрямую
|
||
if "name" in consignee_data:
|
||
result["consignee"] = consignee_data
|
||
# Если только meta - загружаем отдельно
|
||
elif "meta" in consignee_data:
|
||
result["consignee"] = self.get_agent(consignee_data)
|
||
|
||
# Добавляем адрес доставки из заказа, если он указан
|
||
if result.get("consignee") and "shipmentAddress" in order:
|
||
shipment_addr = order["shipmentAddress"]
|
||
if shipment_addr:
|
||
result["consignee"]["deliveryAddress"] = shipment_addr
|
||
|
||
# Позиции отгрузки
|
||
result["positions"] = self.get_order_positions(order_id)
|
||
|
||
logger.info(f"Получены полные данные отгрузки {order_id}")
|
||
return result
|
||
|
||
def get_print_templates(self, entity_type: str = "demand") -> List[Dict]:
|
||
"""
|
||
Получение списка доступных встроенных шаблонов печатных форм
|
||
|
||
Args:
|
||
entity_type: Тип документа (demand, customerorder и т.д.)
|
||
|
||
Returns:
|
||
Список встроенных шаблонов
|
||
"""
|
||
endpoint = f"entity/{entity_type}/metadata"
|
||
logger.info(f"Получение шаблонов печати для {entity_type}")
|
||
|
||
metadata = self._make_request("GET", endpoint)
|
||
|
||
# Встроенные шаблоны из states
|
||
templates = []
|
||
|
||
# Извлекаем embeddedTemplates если есть
|
||
embedded = metadata.get("embeddedTemplates", [])
|
||
if embedded:
|
||
templates.extend(embedded)
|
||
|
||
# Извлекаем customTemplates если есть
|
||
custom = metadata.get("customTemplates", [])
|
||
if custom:
|
||
templates.extend(custom)
|
||
|
||
logger.info(f"Найдено {len(templates)} шаблонов")
|
||
return templates
|
||
|
||
def create_publication(
|
||
self,
|
||
order_id: str,
|
||
template_type: str = "embedded",
|
||
template_name: str = None,
|
||
entity_type: str = "demand"
|
||
) -> str:
|
||
"""
|
||
Создание публикации документа для получения печатной формы
|
||
|
||
Args:
|
||
order_id: ID отгрузки
|
||
template_type: Тип шаблона (embedded/custom/standard)
|
||
template_name: Название шаблона или None для стандартного
|
||
entity_type: Тип документа
|
||
|
||
Returns:
|
||
URL для скачивания печатной формы
|
||
"""
|
||
if order_id.startswith("http"):
|
||
order_id = order_id.split("/")[-1]
|
||
if "?" in order_id:
|
||
order_id = order_id.split("?")[0]
|
||
|
||
endpoint = f"entity/{entity_type}/{order_id}/publication"
|
||
|
||
# Данные для создания публикации
|
||
publication_data = {
|
||
"template": {
|
||
"meta": {
|
||
"href": (
|
||
f"{self.api_url}/entity/{entity_type}/metadata/"
|
||
f"{template_type}/{template_name}"
|
||
if template_name
|
||
else f"{self.api_url}/entity/{entity_type}/metadata"
|
||
),
|
||
"type": "embeddedtemplate",
|
||
"mediaType": "application/json"
|
||
}
|
||
}
|
||
}
|
||
|
||
logger.info(f"Создание публикации для {order_id}")
|
||
|
||
result = self._make_request(
|
||
"POST", endpoint, json_data=publication_data
|
||
)
|
||
|
||
# Извлекаем URL печатной формы
|
||
template_meta = result.get("template", {}).get("meta", {})
|
||
download_href = template_meta.get("downloadHref", "")
|
||
|
||
logger.info(f"Публикация создана: {download_href}")
|
||
return download_href
|
||
|
||
def download_print_form(
|
||
self,
|
||
order_id: str,
|
||
template_name: str = None,
|
||
entity_type: str = "demand",
|
||
output_path: Optional[str] = None
|
||
) -> bytes:
|
||
"""
|
||
Скачивание печатной формы через publications API
|
||
|
||
Args:
|
||
order_id: ID отгрузки
|
||
template_name: Название шаблона (None = стандартный)
|
||
entity_type: Тип документа
|
||
output_path: Путь для сохранения файла
|
||
|
||
Returns:
|
||
Содержимое файла в байтах
|
||
"""
|
||
if order_id.startswith("http"):
|
||
order_id = order_id.split("/")[-1]
|
||
if "?" in order_id:
|
||
order_id = order_id.split("?")[0]
|
||
|
||
# Простой способ: прямой запрос к эндпоинту export
|
||
if template_name:
|
||
# Кастомный шаблон
|
||
url = (
|
||
f"{self.api_url}/entity/{entity_type}/{order_id}/"
|
||
f"export/{template_name}"
|
||
)
|
||
else:
|
||
# Стандартная печатная форма (без шаблона)
|
||
url = f"{self.api_url}/entity/{entity_type}/{order_id}/export"
|
||
|
||
logger.info(f"Скачивание печатной формы для {order_id}")
|
||
|
||
try:
|
||
response = requests.get(
|
||
url,
|
||
headers=self.headers,
|
||
timeout=60
|
||
)
|
||
|
||
if response.status_code >= 400:
|
||
error_msg = (
|
||
f"Ошибка скачивания печатной формы: "
|
||
f"{response.status_code}"
|
||
)
|
||
logger.error(error_msg)
|
||
raise MoySkladAPIError(error_msg)
|
||
|
||
content = response.content
|
||
|
||
# Сохранение в файл если указан путь
|
||
if output_path:
|
||
with open(output_path, 'wb') as f:
|
||
f.write(content)
|
||
logger.info(f"Печатная форма сохранена: {output_path}")
|
||
|
||
return content
|
||
|
||
except requests.exceptions.RequestException as e:
|
||
error_msg = f"Ошибка скачивания: {str(e)}"
|
||
logger.error(error_msg)
|
||
raise MoySkladAPIError(error_msg)
|
||
|
||
def test_connection(self) -> bool:
|
||
"""
|
||
Проверка подключения к API
|
||
|
||
Returns:
|
||
True если подключение успешно, False иначе
|
||
"""
|
||
try:
|
||
# Попытка получить текущую организацию
|
||
self._make_request("GET", "context/employee")
|
||
logger.info("✅ Подключение к API МойСклад успешно")
|
||
return True
|
||
except MoySkladAPIError as e:
|
||
logger.error(f"❌ Ошибка подключения к API: {e}")
|
||
return False
|
||
|
||
def print_document(
|
||
self,
|
||
entity_type: str,
|
||
entity_id: str,
|
||
template_id: str,
|
||
template_type: str = "customtemplate",
|
||
extension: str = "xls",
|
||
output_path: Optional[str] = None
|
||
) -> str:
|
||
"""
|
||
Печать документа через API МойСклад
|
||
|
||
Args:
|
||
entity_type: Тип сущности (demand, customerorder и т.д.)
|
||
entity_id: ID сущности
|
||
template_id: ID шаблона печатной формы
|
||
template_type: Тип шаблона (embeddedtemplate или customtemplate)
|
||
extension: Расширение файла (xls, pdf, html, ods)
|
||
output_path: Путь для сохранения (если None, генерируется автоматически)
|
||
|
||
Returns:
|
||
Путь к сохранённому файлу
|
||
|
||
Raises:
|
||
MoySkladAPIError: При ошибке печати
|
||
"""
|
||
logger.info(f"Печать {entity_type}/{entity_id} с шаблоном {template_id}")
|
||
|
||
# Формируем запрос
|
||
request_body = {
|
||
"template": {
|
||
"meta": {
|
||
"href": f"{self.api_url}/entity/{entity_type}/metadata/{template_type}/{template_id}",
|
||
"type": template_type,
|
||
"mediaType": "application/json"
|
||
}
|
||
},
|
||
"extension": extension
|
||
}
|
||
|
||
# POST /entity/{type}/{id}/export/
|
||
response = self._make_request(
|
||
"POST",
|
||
f"entity/{entity_type}/{entity_id}/export",
|
||
json_data=request_body,
|
||
return_raw=True
|
||
)
|
||
|
||
# Обработка ответа
|
||
if response.status_code == 200:
|
||
# Файл готов в теле ответа
|
||
if output_path is None:
|
||
output_path = f"output/{entity_type}_{entity_id}.{extension}"
|
||
|
||
with open(output_path, 'wb') as f:
|
||
f.write(response.content)
|
||
|
||
logger.info(f"✅ Документ сохранён: {output_path} ({len(response.content)} bytes)")
|
||
return output_path
|
||
|
||
elif response.status_code == 303:
|
||
# Редирект на готовый файл
|
||
location = response.headers.get('Location')
|
||
if not location:
|
||
raise MoySkladAPIError("Нет Location в ответе 303")
|
||
|
||
logger.info(f"Скачивание по Location: {location}")
|
||
|
||
import requests
|
||
download_resp = requests.get(
|
||
location,
|
||
auth=self.auth,
|
||
timeout=30
|
||
)
|
||
|
||
if download_resp.status_code != 200:
|
||
raise MoySkladAPIError(f"Ошибка скачивания: {download_resp.status_code}")
|
||
|
||
if output_path is None:
|
||
output_path = f"output/{entity_type}_{entity_id}.{extension}"
|
||
|
||
with open(output_path, 'wb') as f:
|
||
f.write(download_resp.content)
|
||
|
||
logger.info(f"✅ Документ сохранён: {output_path}")
|
||
return output_path
|
||
|
||
elif response.status_code == 202:
|
||
# Документ готовится, нужно опросить статус
|
||
location = response.headers.get('Location')
|
||
if not location:
|
||
raise MoySkladAPIError("Нет Location в ответе 202")
|
||
|
||
logger.info("⏳ Документ готовится, опрашиваем статус...")
|
||
|
||
# Опрашиваем статус (макс 30 секунд)
|
||
import time
|
||
for i in range(30):
|
||
time.sleep(1)
|
||
|
||
status_resp = self._make_request(
|
||
"GET",
|
||
location.replace(self.api_url + "/", ""),
|
||
return_raw=True
|
||
)
|
||
|
||
if status_resp.status_code == 303:
|
||
# Готово!
|
||
file_location = status_resp.headers.get('Location')
|
||
if not file_location:
|
||
raise MoySkladAPIError("Нет Location в ответе статуса")
|
||
|
||
import requests
|
||
download_resp = requests.get(
|
||
file_location,
|
||
auth=self.auth,
|
||
timeout=30
|
||
)
|
||
|
||
if download_resp.status_code != 200:
|
||
raise MoySkladAPIError(f"Ошибка скачивания: {download_resp.status_code}")
|
||
|
||
if output_path is None:
|
||
output_path = f"output/{entity_type}_{entity_id}.{extension}"
|
||
|
||
with open(output_path, 'wb') as f:
|
||
f.write(download_resp.content)
|
||
|
||
logger.info(f"✅ Документ сохранён: {output_path}")
|
||
return output_path
|
||
|
||
raise MoySkladAPIError("Timeout: документ не готов за 30 секунд")
|
||
|
||
else:
|
||
raise MoySkladAPIError(f"Неожиданный статус: {response.status_code}, {response.text[:200]}")
|
||
|
||
def get_templates(
|
||
self,
|
||
entity_type: str,
|
||
template_type: str = "customtemplate"
|
||
) -> List[Dict]:
|
||
"""
|
||
Получение списка шаблонов для типа сущности
|
||
|
||
Args:
|
||
entity_type: Тип сущности (demand, customerorder и т.д.)
|
||
template_type: Тип шаблона (embeddedtemplate или customtemplate)
|
||
|
||
Returns:
|
||
Список шаблонов
|
||
"""
|
||
logger.info(f"Получение {template_type} для {entity_type}")
|
||
|
||
response = self._make_request(
|
||
"GET",
|
||
f"entity/{entity_type}/metadata/{template_type}"
|
||
)
|
||
|
||
templates = response if isinstance(response, list) else response.get("rows", [])
|
||
logger.info(f"Найдено шаблонов: {len(templates)}")
|
||
|
||
return templates
|
||
|
||
|
||
# Пример использования
|
||
|