""" Клиент для работы с 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 # Пример использования