BUH_bot/modules/moysklad_client.py
2026-06-19 13:51:59 +03:00

828 lines
32 KiB
Python
Raw Permalink 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.

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