Initial commit: Core bot application
- Add main bot file with Telegram handlers - Add configuration module - Add Python dependencies - Add modules: excel_generator, moysklad_client, xml_generator, user_manager - Add .gitignore for Python, Docker, user data
This commit is contained in:
commit
5b6ab02e53
67
.gitignore
vendored
Normal file
67
.gitignore
vendored
Normal file
@ -0,0 +1,67 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# User data (credentials storage)
|
||||
users.json
|
||||
.encryption_key
|
||||
data/
|
||||
|
||||
# Docker volumes
|
||||
logs/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project specific
|
||||
output/
|
||||
logs/
|
||||
*.log
|
||||
temp_documents/
|
||||
*.json
|
||||
nul
|
||||
|
||||
# Test files (temporary)
|
||||
test_*.py
|
||||
check_*.py
|
||||
list_*.py
|
||||
download_*.py
|
||||
|
||||
# Test coverage
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
62
config.py
Normal file
62
config.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""
|
||||
Конфигурация приложения
|
||||
Загрузка настроек из переменных окружения
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import Field
|
||||
|
||||
# Загрузка .env файла
|
||||
load_dotenv()
|
||||
|
||||
# Базовая директория проекта
|
||||
BASE_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Настройки приложения"""
|
||||
|
||||
# МойСклад API
|
||||
moysklad_login: str = Field(..., env='MOYSKLAD_LOGIN')
|
||||
moysklad_password: str = Field(..., env='MOYSKLAD_PASSWORD')
|
||||
moysklad_api_url: str = Field(
|
||||
default='https://api.moysklad.ru/api/remap/1.2',
|
||||
env='MOYSKLAD_API_URL'
|
||||
)
|
||||
|
||||
# Telegram Bot
|
||||
telegram_bot_token: str = Field(..., env='TELEGRAM_BOT_TOKEN')
|
||||
|
||||
# Настройки приложения
|
||||
log_level: str = Field(default='INFO', env='LOG_LEVEL')
|
||||
debug: bool = Field(default=False, env='DEBUG')
|
||||
|
||||
# Пути
|
||||
output_dir: Path = Field(default=BASE_DIR / 'output', env='OUTPUT_DIR')
|
||||
templates_dir: Path = Field(default=BASE_DIR / 'templates', env='TEMPLATES_DIR')
|
||||
|
||||
# Форматы документов
|
||||
xml_version: str = '5.03'
|
||||
encoding: str = 'UTF-8'
|
||||
|
||||
class Config:
|
||||
env_file = '.env'
|
||||
env_file_encoding = 'utf-8'
|
||||
case_sensitive = False
|
||||
|
||||
|
||||
# Создание экземпляра настроек
|
||||
try:
|
||||
settings = Settings()
|
||||
|
||||
# Создание необходимых директорий
|
||||
settings.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
settings.templates_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Ошибка загрузки настроек: {e}")
|
||||
print("Убедитесь, что файл .env существует и содержит все необходимые параметры")
|
||||
print("Скопируйте .env.example в .env и заполните значения")
|
||||
raise
|
||||
3
modules/__init__.py
Normal file
3
modules/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""Пакет модулей бота для бухгалтеров"""
|
||||
|
||||
__version__ = '1.0.0'
|
||||
809
modules/moysklad_client.py
Normal file
809
modules/moysklad_client.py
Normal file
@ -0,0 +1,809 @@
|
||||
"""
|
||||
Клиент для работы с 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("Клиент МойСклад инициализирован")
|
||||
|
||||
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_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:
|
||||
Список банковских счетов
|
||||
"""
|
||||
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 = org_meta["meta"]["href"].split("/")[-1]
|
||||
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 "meta" in agent_meta:
|
||||
agent_id = agent_meta["meta"]["href"].split("/")[-1]
|
||||
result["counterparty"] = self.get_counterparty(agent_id)
|
||||
# Получаем банковские счета контрагента
|
||||
result["cp_accounts"] = self.get_accounts(
|
||||
"counterparty", agent_id
|
||||
)
|
||||
|
||||
# Грузополучатель (если указан)
|
||||
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:
|
||||
consignee_id = consignee_data["meta"]["href"].split("/")[-1]
|
||||
result["consignee"] = self.get_counterparty(consignee_id)
|
||||
|
||||
# Позиции отгрузки
|
||||
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
|
||||
|
||||
|
||||
# Пример использования
|
||||
if __name__ == "__main__":
|
||||
from config import settings
|
||||
|
||||
# Настройка логирования
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
# Создание клиента
|
||||
client = MoySkladClient(
|
||||
login=settings.moysklad_login,
|
||||
password=settings.moysklad_password,
|
||||
api_url=settings.moysklad_api_url
|
||||
)
|
||||
|
||||
# Проверка подключения
|
||||
if client.test_connection():
|
||||
# Получение последних отгрузок
|
||||
orders = client.get_orders(limit=5)
|
||||
print(f"\nПолучено отгрузок: {len(orders.get('rows', []))}")
|
||||
|
||||
# Вывод информации об отгрузках
|
||||
for order in orders.get("rows", []):
|
||||
print(f"\nОтгрузка №{order.get('name')}")
|
||||
print(f" Дата: {order.get('moment')}")
|
||||
print(f" Сумма: {order.get('sum', 0) / 100:.2f} руб.")
|
||||
print(f" ID: {order['meta']['href'].split('/')[-1]}")
|
||||
205
modules/user_manager.py
Normal file
205
modules/user_manager.py
Normal file
@ -0,0 +1,205 @@
|
||||
"""
|
||||
Управление пользователями и их учетными данными МойСклад
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserManager:
|
||||
"""Управление пользователями и их учетными данными"""
|
||||
|
||||
def __init__(self, storage_file: str = None):
|
||||
"""
|
||||
Инициализация менеджера пользователей
|
||||
|
||||
Args:
|
||||
storage_file: Путь к файлу хранения данных
|
||||
"""
|
||||
# Поддержка Docker volumes
|
||||
data_dir = os.getenv('DATA_DIR', '.')
|
||||
|
||||
if storage_file is None:
|
||||
storage_file = os.path.join(data_dir, "users.json")
|
||||
|
||||
self.storage_path = Path(storage_file)
|
||||
self.key_file = Path(data_dir) / ".encryption_key"
|
||||
self.users: Dict[int, Dict] = {}
|
||||
|
||||
# Создаём data директорию если не существует
|
||||
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._load_users()
|
||||
|
||||
# Генерация ключа шифрования
|
||||
self.cipher_key = self._get_or_create_key()
|
||||
self.cipher = Fernet(self.cipher_key)
|
||||
|
||||
def _get_or_create_key(self) -> bytes:
|
||||
"""Получает или создаёт ключ шифрования"""
|
||||
if self.key_file.exists():
|
||||
with open(self.key_file, "rb") as f:
|
||||
return f.read()
|
||||
else:
|
||||
key = Fernet.generate_key()
|
||||
with open(self.key_file, "wb") as f:
|
||||
f.write(key)
|
||||
logger.info(f"Создан новый ключ шифрования: {self.key_file}")
|
||||
return key
|
||||
|
||||
def _encrypt(self, text: str) -> str:
|
||||
"""Шифрует текст"""
|
||||
return self.cipher.encrypt(text.encode()).decode()
|
||||
|
||||
def _decrypt(self, encrypted_text: str) -> str:
|
||||
"""Расшифровывает текст"""
|
||||
return self.cipher.decrypt(encrypted_text.encode()).decode()
|
||||
|
||||
def _load_users(self):
|
||||
"""Загружает пользователей из файла"""
|
||||
if self.storage_path.exists():
|
||||
try:
|
||||
with open(self.storage_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
# Конвертируем ключи обратно в int
|
||||
self.users = {int(k): v for k, v in data.items()}
|
||||
logger.info(f"Загружено пользователей: {len(self.users)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки пользователей: {e}")
|
||||
self.users = {}
|
||||
else:
|
||||
logger.info("Файл пользователей не найден, создан новый")
|
||||
self.users = {}
|
||||
|
||||
def _save_users(self):
|
||||
"""Сохраняет пользователей в файл"""
|
||||
try:
|
||||
with open(self.storage_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.users, f, ensure_ascii=False, indent=2)
|
||||
logger.info(f"Сохранено пользователей: {len(self.users)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сохранения пользователей: {e}")
|
||||
|
||||
def is_user_registered(self, user_id: int) -> bool:
|
||||
"""
|
||||
Проверяет, зарегистрирован ли пользователь
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя Telegram
|
||||
|
||||
Returns:
|
||||
True если пользователь зарегистрирован
|
||||
"""
|
||||
return user_id in self.users
|
||||
|
||||
def register_user(
|
||||
self,
|
||||
user_id: int,
|
||||
moysklad_login: str,
|
||||
moysklad_password: str,
|
||||
username: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Регистрирует нового пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя Telegram
|
||||
moysklad_login: Логин МойСклад
|
||||
moysklad_password: Пароль МойСклад
|
||||
username: Username Telegram (опционально)
|
||||
|
||||
Returns:
|
||||
True если успешно зарегистрирован
|
||||
"""
|
||||
try:
|
||||
self.users[user_id] = {
|
||||
"username": username,
|
||||
"moysklad_login": self._encrypt(moysklad_login),
|
||||
"moysklad_password": self._encrypt(moysklad_password),
|
||||
"registered_at": None # Можно добавить timestamp
|
||||
}
|
||||
self._save_users()
|
||||
logger.info(f"Пользователь {user_id} зарегистрирован")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка регистрации пользователя {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def get_user_credentials(self, user_id: int) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Получает учетные данные пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя Telegram
|
||||
|
||||
Returns:
|
||||
Dict с login и password или None
|
||||
"""
|
||||
if user_id not in self.users:
|
||||
return None
|
||||
|
||||
try:
|
||||
user_data = self.users[user_id]
|
||||
return {
|
||||
"login": self._decrypt(user_data["moysklad_login"]),
|
||||
"password": self._decrypt(user_data["moysklad_password"])
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения credentials для {user_id}: {e}")
|
||||
return None
|
||||
|
||||
def update_credentials(
|
||||
self,
|
||||
user_id: int,
|
||||
moysklad_login: str,
|
||||
moysklad_password: str
|
||||
) -> bool:
|
||||
"""
|
||||
Обновляет учетные данные пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя Telegram
|
||||
moysklad_login: Новый логин МойСклад
|
||||
moysklad_password: Новый пароль МойСклад
|
||||
|
||||
Returns:
|
||||
True если успешно обновлено
|
||||
"""
|
||||
if user_id not in self.users:
|
||||
return False
|
||||
|
||||
try:
|
||||
self.users[user_id]["moysklad_login"] = self._encrypt(moysklad_login)
|
||||
self.users[user_id]["moysklad_password"] = self._encrypt(moysklad_password)
|
||||
self._save_users()
|
||||
logger.info(f"Credentials обновлены для пользователя {user_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления credentials для {user_id}: {e}")
|
||||
return False
|
||||
|
||||
def delete_user(self, user_id: int) -> bool:
|
||||
"""
|
||||
Удаляет пользователя
|
||||
|
||||
Args:
|
||||
user_id: ID пользователя Telegram
|
||||
|
||||
Returns:
|
||||
True если успешно удален
|
||||
"""
|
||||
if user_id in self.users:
|
||||
del self.users[user_id]
|
||||
self._save_users()
|
||||
logger.info(f"Пользователь {user_id} удален")
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_user_count(self) -> int:
|
||||
"""Возвращает количество зарегистрированных пользователей"""
|
||||
return len(self.users)
|
||||
513
modules/validator.py
Normal file
513
modules/validator.py
Normal file
@ -0,0 +1,513 @@
|
||||
"""
|
||||
Валидация данных заказов МойСклад
|
||||
Проверка обязательных и желательных полей, валидация форматов
|
||||
"""
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ValidationLevel(Enum):
|
||||
"""Уровни валидации"""
|
||||
VALID = "valid" # Все проверки пройдены
|
||||
WARNING = "warning" # Отсутствуют желательные поля
|
||||
CRITICAL = "critical" # Отсутствуют обязательные поля
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Результат валидации"""
|
||||
level: ValidationLevel
|
||||
field: str
|
||||
message: str
|
||||
value: Optional[str] = None
|
||||
|
||||
|
||||
class OrderValidator:
|
||||
"""Валидатор данных заказов"""
|
||||
|
||||
# НДС ставки
|
||||
VAT_RATES = [0, 10, 20, "без НДС", "0%", "10%", "20%"]
|
||||
|
||||
def __init__(self):
|
||||
self.results: List[ValidationResult] = []
|
||||
|
||||
def validate_inn(self, inn: str) -> bool:
|
||||
"""
|
||||
Валидация ИНН
|
||||
|
||||
ИНН юр. лица: 10 цифр
|
||||
ИНН ИП: 12 цифр
|
||||
Включает проверку контрольной суммы
|
||||
|
||||
Args:
|
||||
inn: ИНН для проверки
|
||||
|
||||
Returns:
|
||||
True если ИНН корректен
|
||||
"""
|
||||
if not inn or not isinstance(inn, str):
|
||||
return False
|
||||
|
||||
inn = inn.strip()
|
||||
|
||||
if not inn.isdigit():
|
||||
return False
|
||||
|
||||
if len(inn) == 10:
|
||||
# ИНН юридического лица (10 цифр)
|
||||
coefficients = [2, 4, 10, 3, 5, 9, 4, 6, 8]
|
||||
checksum = sum(
|
||||
int(inn[i]) * coefficients[i] for i in range(9)
|
||||
) % 11 % 10
|
||||
return checksum == int(inn[9])
|
||||
|
||||
elif len(inn) == 12:
|
||||
# ИНН индивидуального предпринимателя (12 цифр)
|
||||
# Проверка первой контрольной суммы
|
||||
coefficients1 = [7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
|
||||
checksum1 = sum(
|
||||
int(inn[i]) * coefficients1[i] for i in range(10)
|
||||
) % 11 % 10
|
||||
|
||||
# Проверка второй контрольной суммы
|
||||
coefficients2 = [3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8]
|
||||
checksum2 = sum(
|
||||
int(inn[i]) * coefficients2[i] for i in range(11)
|
||||
) % 11 % 10
|
||||
|
||||
return checksum1 == int(inn[10]) and checksum2 == int(inn[11])
|
||||
|
||||
return False
|
||||
|
||||
def validate_kpp(self, kpp: str) -> bool:
|
||||
"""
|
||||
Валидация КПП
|
||||
|
||||
КПП: 9 цифр
|
||||
|
||||
Args:
|
||||
kpp: КПП для проверки
|
||||
|
||||
Returns:
|
||||
True если КПП корректен
|
||||
"""
|
||||
if not kpp or not isinstance(kpp, str):
|
||||
return False
|
||||
|
||||
kpp = kpp.strip()
|
||||
return len(kpp) == 9 and kpp.isdigit()
|
||||
|
||||
def validate_ogrn(self, ogrn: str) -> bool:
|
||||
"""
|
||||
Валидация ОГРН
|
||||
|
||||
ОГРН юр. лица: 13 цифр
|
||||
ОГРНИП: 15 цифр
|
||||
|
||||
Args:
|
||||
ogrn: ОГРН для проверки
|
||||
|
||||
Returns:
|
||||
True если ОГРН корректен
|
||||
"""
|
||||
if not ogrn or not isinstance(ogrn, str):
|
||||
return False
|
||||
|
||||
ogrn = ogrn.strip()
|
||||
|
||||
if not ogrn.isdigit():
|
||||
return False
|
||||
|
||||
if len(ogrn) == 13:
|
||||
# ОГРН юридического лица
|
||||
check = int(ogrn[:12]) % 11
|
||||
return (check % 10) == int(ogrn[12])
|
||||
|
||||
elif len(ogrn) == 15:
|
||||
# ОГРНИП индивидуального предпринимателя
|
||||
check = int(ogrn[:14]) % 13
|
||||
return (check % 10) == int(ogrn[14])
|
||||
|
||||
return False
|
||||
|
||||
def validate_organization(self, org: Dict) -> List[ValidationResult]:
|
||||
"""
|
||||
Валидация данных организации (продавец)
|
||||
|
||||
Args:
|
||||
org: Данные организации из МойСклад
|
||||
|
||||
Returns:
|
||||
Список результатов валидации
|
||||
"""
|
||||
results = []
|
||||
|
||||
if not org:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="organization",
|
||||
message="Отсутствуют данные организации"
|
||||
))
|
||||
return results
|
||||
|
||||
# Обязательные поля
|
||||
name = org.get("name") or org.get("legalTitle")
|
||||
if not name:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="organization.name",
|
||||
message="Не указано наименование организации"
|
||||
))
|
||||
|
||||
# ИНН
|
||||
inn = org.get("inn")
|
||||
if not inn:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="organization.inn",
|
||||
message="Не указан ИНН организации"
|
||||
))
|
||||
elif not self.validate_inn(inn):
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="organization.inn",
|
||||
message=f"Некорректный ИНН: {inn}",
|
||||
value=inn
|
||||
))
|
||||
|
||||
# КПП (обязателен для юр. лиц)
|
||||
kpp = org.get("kpp")
|
||||
if inn and len(inn) == 10: # Юр. лицо
|
||||
if not kpp:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="organization.kpp",
|
||||
message="Не указан КПП организации"
|
||||
))
|
||||
elif not self.validate_kpp(kpp):
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="organization.kpp",
|
||||
message=f"Некорректный КПП: {kpp}",
|
||||
value=kpp
|
||||
))
|
||||
|
||||
# Юридический адрес
|
||||
legal_address = (
|
||||
org.get("legalAddress") or
|
||||
org.get("actualAddress")
|
||||
)
|
||||
if not legal_address:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="organization.legalAddress",
|
||||
message="Не указан юридический адрес организации"
|
||||
))
|
||||
|
||||
# Желательные поля
|
||||
|
||||
# ОГРН
|
||||
ogrn = org.get("ogrn")
|
||||
if not ogrn:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.WARNING,
|
||||
field="organization.ogrn",
|
||||
message="Не указан ОГРН организации"
|
||||
))
|
||||
elif not self.validate_ogrn(ogrn):
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.WARNING,
|
||||
field="organization.ogrn",
|
||||
message=f"Некорректный ОГРН: {ogrn}",
|
||||
value=ogrn
|
||||
))
|
||||
|
||||
# Счета
|
||||
accounts = org.get("accounts", [])
|
||||
if not accounts:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.WARNING,
|
||||
field="organization.accounts",
|
||||
message="Не указаны банковские реквизиты"
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
def validate_counterparty(self, agent: Dict) -> List[ValidationResult]:
|
||||
"""
|
||||
Валидация данных контрагента (покупатель)
|
||||
|
||||
Args:
|
||||
agent: Данные контрагента из МойСклад
|
||||
|
||||
Returns:
|
||||
Список результатов валидации
|
||||
"""
|
||||
results = []
|
||||
|
||||
if not agent:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="counterparty",
|
||||
message="Отсутствуют данные контрагента"
|
||||
))
|
||||
return results
|
||||
|
||||
# Обязательные поля
|
||||
name = agent.get("name") or agent.get("legalTitle")
|
||||
if not name:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="counterparty.name",
|
||||
message="Не указано наименование контрагента"
|
||||
))
|
||||
|
||||
# ИНН
|
||||
inn = agent.get("inn")
|
||||
if not inn:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="counterparty.inn",
|
||||
message="Не указан ИНН контрагента"
|
||||
))
|
||||
elif not self.validate_inn(inn):
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="counterparty.inn",
|
||||
message=f"Некорректный ИНН: {inn}",
|
||||
value=inn
|
||||
))
|
||||
|
||||
# КПП (обязателен для юр. лиц)
|
||||
kpp = agent.get("kpp")
|
||||
if inn and len(inn) == 10: # Юр. лицо
|
||||
if not kpp:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="counterparty.kpp",
|
||||
message="Не указан КПП контрагента"
|
||||
))
|
||||
elif not self.validate_kpp(kpp):
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="counterparty.kpp",
|
||||
message=f"Некорректный КПП: {kpp}",
|
||||
value=kpp
|
||||
))
|
||||
|
||||
# Адрес
|
||||
address = agent.get("legalAddress") or agent.get("actualAddress")
|
||||
if not address:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="counterparty.address",
|
||||
message="Не указан адрес контрагента"
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
def validate_position(
|
||||
self,
|
||||
position: Dict,
|
||||
index: int
|
||||
) -> List[ValidationResult]:
|
||||
"""
|
||||
Валидация позиции заказа (товар)
|
||||
|
||||
Args:
|
||||
position: Данные позиции
|
||||
index: Номер позиции
|
||||
|
||||
Returns:
|
||||
Список результатов валидации
|
||||
"""
|
||||
results = []
|
||||
|
||||
# Наименование товара
|
||||
assortment = position.get("assortment", {})
|
||||
name = assortment.get("name")
|
||||
if not name:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field=f"position[{index}].name",
|
||||
message=f"Позиция {index + 1}: не указано наименование"
|
||||
))
|
||||
|
||||
# Количество
|
||||
quantity = position.get("quantity")
|
||||
if not quantity or quantity <= 0:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field=f"position[{index}].quantity",
|
||||
message=f"Позиция {index + 1}: некорректное количество"
|
||||
))
|
||||
|
||||
# Цена
|
||||
price = position.get("price")
|
||||
if price is None or price < 0:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field=f"position[{index}].price",
|
||||
message=f"Позиция {index + 1}: некорректная цена"
|
||||
))
|
||||
|
||||
# НДС
|
||||
vat = position.get("vat")
|
||||
if vat is None:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.WARNING,
|
||||
field=f"position[{index}].vat",
|
||||
message=f"Позиция {index + 1}: не указана ставка НДС"
|
||||
))
|
||||
|
||||
# Единица измерения (желательно)
|
||||
uom = assortment.get("uom")
|
||||
if not uom:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.WARNING,
|
||||
field=f"position[{index}].uom",
|
||||
message=f"Позиция {index + 1}: не указана единица измерения"
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
def validate_order(self, order_data: Dict) -> List[ValidationResult]:
|
||||
"""
|
||||
Полная валидация заказа
|
||||
|
||||
Args:
|
||||
order_data: Полные данные заказа (из get_full_order_data)
|
||||
|
||||
Returns:
|
||||
Список всех результатов валидации
|
||||
"""
|
||||
results = []
|
||||
|
||||
order = order_data.get("order", {})
|
||||
|
||||
# Основные данные заказа
|
||||
if not order.get("name"):
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="order.name",
|
||||
message="Не указан номер заказа"
|
||||
))
|
||||
|
||||
if not order.get("moment"):
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="order.moment",
|
||||
message="Не указана дата заказа"
|
||||
))
|
||||
|
||||
total_sum = order.get("sum", 0)
|
||||
if total_sum <= 0:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="order.sum",
|
||||
message="Некорректная сумма заказа"
|
||||
))
|
||||
|
||||
# Валидация организации
|
||||
org = order_data.get("organization")
|
||||
results.extend(self.validate_organization(org))
|
||||
|
||||
# Валидация контрагента
|
||||
agent = order_data.get("counterparty")
|
||||
results.extend(self.validate_counterparty(agent))
|
||||
|
||||
# Валидация грузополучателя (КРИТИЧНО!)
|
||||
# Если грузополучатель не указан явно, используем контрагента
|
||||
consignee = order_data.get("consignee")
|
||||
if consignee is None:
|
||||
# Используем контрагента как грузополучателя по умолчанию
|
||||
consignee = order_data.get("counterparty")
|
||||
if consignee is None:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="order.consignee",
|
||||
message="Не указан грузополучатель (обязательно для УПД)"
|
||||
))
|
||||
|
||||
# Проверяем наличие базовых данных грузополучателя
|
||||
if consignee and isinstance(consignee, dict):
|
||||
if not consignee.get("name"):
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="consignee.name",
|
||||
message="Не указано наименование грузополучателя"
|
||||
))
|
||||
|
||||
# Валидация позиций
|
||||
positions = order_data.get("positions", [])
|
||||
if not positions:
|
||||
results.append(ValidationResult(
|
||||
level=ValidationLevel.CRITICAL,
|
||||
field="order.positions",
|
||||
message="Заказ не содержит товарных позиций"
|
||||
))
|
||||
else:
|
||||
for index, position in enumerate(positions):
|
||||
results.extend(self.validate_position(position, index))
|
||||
|
||||
return results
|
||||
|
||||
def get_validation_summary(
|
||||
self,
|
||||
results: List[ValidationResult]
|
||||
) -> Tuple[ValidationLevel, str]:
|
||||
"""
|
||||
Получение сводки по результатам валидации
|
||||
|
||||
Args:
|
||||
results: Список результатов валидации
|
||||
|
||||
Returns:
|
||||
Tuple: (общий уровень, текстовая сводка)
|
||||
"""
|
||||
if not results:
|
||||
return ValidationLevel.VALID, "✅ Все проверки пройдены успешно"
|
||||
|
||||
critical = [r for r in results if r.level == ValidationLevel.CRITICAL]
|
||||
warnings = [r for r in results if r.level == ValidationLevel.WARNING]
|
||||
|
||||
if critical:
|
||||
level = ValidationLevel.CRITICAL
|
||||
summary = f"❌ Критических ошибок: {len(critical)}\n"
|
||||
if warnings:
|
||||
summary += f"⚠️ Предупреждений: {len(warnings)}\n"
|
||||
summary += "\nКритические ошибки:\n"
|
||||
for r in critical:
|
||||
summary += f" • {r.message}\n"
|
||||
elif warnings:
|
||||
level = ValidationLevel.WARNING
|
||||
summary = f"⚠️ Предупреждений: {len(warnings)}\n"
|
||||
summary += "\nПредупреждения:\n"
|
||||
for r in warnings:
|
||||
summary += f" • {r.message}\n"
|
||||
else:
|
||||
level = ValidationLevel.VALID
|
||||
summary = "✅ Все проверки пройдены"
|
||||
|
||||
return level, summary
|
||||
|
||||
|
||||
# Пример использования
|
||||
if __name__ == "__main__":
|
||||
validator = OrderValidator()
|
||||
|
||||
# Тест валидации ИНН
|
||||
test_inns = [
|
||||
"7707083893", # Верный ИНН (10 цифр)
|
||||
"500100732259", # Верный ИНН ИП (12 цифр)
|
||||
"1234567890", # Неверный ИНН
|
||||
]
|
||||
|
||||
for inn in test_inns:
|
||||
result = validator.validate_inn(inn)
|
||||
print(f"ИНН {inn}: {'✅ корректен' if result else '❌ некорректен'}")
|
||||
666
modules/xml_generator.py
Normal file
666
modules/xml_generator.py
Normal file
@ -0,0 +1,666 @@
|
||||
"""
|
||||
Генерация XML документов в формате ВерсФорм 5.03
|
||||
УПД (Универсальный передаточный документ) и ТН (Товарная накладная)
|
||||
"""
|
||||
from lxml import etree
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class XMLGenerator:
|
||||
"""Генератор XML документов в формате ВерсФорм 5.03"""
|
||||
|
||||
VERSION = "5.03"
|
||||
PROGRAM_VERSION = "BUH_BOT_1.0"
|
||||
|
||||
def __init__(self):
|
||||
self.encoding = "UTF-8"
|
||||
|
||||
def _format_date(self, date_str: str) -> str:
|
||||
"""
|
||||
Форматирование даты в формат ДД.ММ.ГГГГ
|
||||
|
||||
Args:
|
||||
date_str: Дата в ISO формате
|
||||
|
||||
Returns:
|
||||
Дата в формате ДД.ММ.ГГГГ
|
||||
"""
|
||||
try:
|
||||
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
return dt.strftime("%d.%m.%Y")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка форматирования даты: {e}")
|
||||
return datetime.now().strftime("%d.%m.%Y")
|
||||
|
||||
def _format_money(self, amount: int) -> str:
|
||||
"""
|
||||
Форматирование денежной суммы (копейки -> рубли)
|
||||
|
||||
Args:
|
||||
amount: Сумма в копейках
|
||||
|
||||
Returns:
|
||||
Сумма в рублях с 2 знаками после запятой
|
||||
"""
|
||||
return f"{amount / 100:.2f}"
|
||||
|
||||
def _add_seller_info(
|
||||
self,
|
||||
parent: etree.Element,
|
||||
organization: Dict,
|
||||
accounts: list = None
|
||||
) -> None:
|
||||
"""
|
||||
Добавление информации о продавце
|
||||
|
||||
Args:
|
||||
parent: Родительский XML элемент
|
||||
organization: Данные организации
|
||||
accounts: Список банковских счетов
|
||||
"""
|
||||
seller = etree.SubElement(parent, "СвПрод")
|
||||
|
||||
# ОКПО
|
||||
okpo = organization.get("okpo", "")
|
||||
if okpo:
|
||||
seller.set("ОКПО", okpo)
|
||||
|
||||
# ИдСв - зависит от типа организации
|
||||
seller_info = etree.SubElement(seller, "ИдСв")
|
||||
|
||||
company_type = organization.get("companyType", "")
|
||||
inn = organization.get("inn", "")
|
||||
|
||||
if company_type == "entrepreneur":
|
||||
# Индивидуальный предприниматель
|
||||
ogrnip = organization.get("ogrnip", "")
|
||||
sv_ip = etree.SubElement(
|
||||
seller_info, "СвИП",
|
||||
ИННФЛ=inn,
|
||||
СвГосРегИП=ogrnip if ogrnip else ""
|
||||
)
|
||||
|
||||
# ФИО
|
||||
last_name = organization.get("legalLastName", "")
|
||||
first_name = organization.get("legalFirstName", "")
|
||||
middle_name = organization.get("legalMiddleName", "")
|
||||
|
||||
if last_name or first_name:
|
||||
fio = etree.SubElement(sv_ip, "ФИО")
|
||||
if last_name:
|
||||
fio.set("Фамилия", last_name)
|
||||
if first_name:
|
||||
fio.set("Имя", first_name)
|
||||
if middle_name:
|
||||
fio.set("Отчество", middle_name)
|
||||
else:
|
||||
# Юридическое лицо
|
||||
kpp = organization.get("kpp", "")
|
||||
sv_ul = etree.SubElement(
|
||||
seller_info, "СвЮЛУч",
|
||||
НаимОрг=organization.get("name", ""),
|
||||
ИННЮЛ=inn,
|
||||
КПП=kpp if kpp else ""
|
||||
)
|
||||
|
||||
# Адрес
|
||||
address = organization.get("legalAddress", "")
|
||||
if address:
|
||||
addr_element = etree.SubElement(seller, "Адрес")
|
||||
etree.SubElement(
|
||||
addr_element, "АдрТекст",
|
||||
Текст=address
|
||||
)
|
||||
|
||||
# Банковские реквизиты
|
||||
if accounts and len(accounts) > 0:
|
||||
# Берём первый счёт с БИК
|
||||
for acc in accounts:
|
||||
if acc.get('bic'):
|
||||
bank_info = etree.SubElement(
|
||||
seller, "БанкРекв",
|
||||
НомерСчета=acc.get('accountNumber', '')
|
||||
)
|
||||
etree.SubElement(
|
||||
bank_info, "СвБанк",
|
||||
НаимБанк=acc.get('bankName', ''),
|
||||
БИК=acc.get('bic', ''),
|
||||
КорСчет=acc.get('correspondentAccount', '')
|
||||
)
|
||||
break
|
||||
|
||||
# Контактная информация
|
||||
phone = organization.get("phone", "")
|
||||
email = organization.get("email", "")
|
||||
if phone or email:
|
||||
contact = etree.SubElement(seller, "Контакт")
|
||||
if phone:
|
||||
etree.SubElement(contact, "Тлф").text = phone
|
||||
if email:
|
||||
etree.SubElement(contact, "ЭлПочта").text = email
|
||||
|
||||
def _add_shipper_info(
|
||||
self,
|
||||
parent: etree.Element,
|
||||
counterparty: Dict,
|
||||
accounts: list = None
|
||||
) -> None:
|
||||
"""
|
||||
Добавление информации о грузополучателе
|
||||
|
||||
Args:
|
||||
parent: Родительский XML элемент
|
||||
counterparty: Данные контрагента
|
||||
accounts: Список банковских счетов
|
||||
"""
|
||||
shipper = etree.SubElement(parent, "ГрузПолуч")
|
||||
|
||||
# ОКПО
|
||||
okpo = counterparty.get("okpo", "")
|
||||
if okpo:
|
||||
shipper.set("ОКПО", okpo)
|
||||
|
||||
# ИНН/КПП и определение типа компании
|
||||
inn = counterparty.get("inn", "")
|
||||
kpp = counterparty.get("kpp", "")
|
||||
company_type = counterparty.get("companyType", "legal")
|
||||
|
||||
shipper_info = etree.SubElement(shipper, "ИдСв")
|
||||
|
||||
# Для ИП используем структуру СвИП
|
||||
if company_type == "entrepreneur":
|
||||
ogrnip = counterparty.get("ogrnip", "")
|
||||
|
||||
sv_ip = etree.SubElement(
|
||||
shipper_info, "СвИП",
|
||||
ИННФЛ=inn,
|
||||
СвГосРегИП=ogrnip
|
||||
)
|
||||
|
||||
# ФИО для ИП
|
||||
last_name = counterparty.get("legalLastName", "")
|
||||
first_name = counterparty.get("legalFirstName", "")
|
||||
middle_name = counterparty.get("legalMiddleName", "")
|
||||
|
||||
fio = etree.SubElement(sv_ip, "ФИО")
|
||||
fio.set("Фамилия", last_name)
|
||||
fio.set("Имя", first_name)
|
||||
if middle_name:
|
||||
fio.set("Отчество", middle_name)
|
||||
else:
|
||||
# Для юрлиц используем СвЮЛУч
|
||||
shipper_info.set("СвЮЛУч", inn)
|
||||
shipper_info.set("КПП", kpp if kpp else "")
|
||||
|
||||
# Наименование
|
||||
name = counterparty.get("name", "")
|
||||
if not name:
|
||||
name = counterparty.get("legalTitle", "")
|
||||
etree.SubElement(shipper_info, "НаимОрг", Наим=name)
|
||||
|
||||
# Адрес
|
||||
address = counterparty.get("legalAddress", "")
|
||||
if address:
|
||||
addr_element = etree.SubElement(shipper, "Адрес")
|
||||
etree.SubElement(
|
||||
addr_element, "АдрТекст",
|
||||
Текст=address
|
||||
)
|
||||
|
||||
# Банковские реквизиты
|
||||
if accounts and len(accounts) > 0:
|
||||
for acc in accounts:
|
||||
if acc.get('bic'):
|
||||
bank_info = etree.SubElement(
|
||||
shipper, "БанкРекв",
|
||||
НомерСчета=acc.get('accountNumber', '')
|
||||
)
|
||||
etree.SubElement(
|
||||
bank_info, "СвБанк",
|
||||
НаимБанк=acc.get('bankName', ''),
|
||||
БИК=acc.get('bic', ''),
|
||||
КорСчет=acc.get('correspondentAccount', '')
|
||||
)
|
||||
break
|
||||
|
||||
# Контактная информация
|
||||
phone = counterparty.get("phone", "")
|
||||
if phone:
|
||||
contact = etree.SubElement(shipper, "Контакт")
|
||||
etree.SubElement(contact, "Тлф").text = phone
|
||||
|
||||
def _add_buyer_info(
|
||||
self,
|
||||
parent: etree.Element,
|
||||
counterparty: Dict,
|
||||
accounts: list = None
|
||||
) -> None:
|
||||
"""
|
||||
Добавление информации о покупателе
|
||||
|
||||
Args:
|
||||
parent: Родительский XML элемент
|
||||
counterparty: Данные контрагента
|
||||
accounts: Список банковских счетов
|
||||
"""
|
||||
buyer = etree.SubElement(parent, "СвПокуп")
|
||||
|
||||
# ОКПО
|
||||
okpo = counterparty.get("okpo", "")
|
||||
if okpo:
|
||||
buyer.set("ОКПО", okpo)
|
||||
|
||||
# ИНН/КПП и определение типа компании
|
||||
inn = counterparty.get("inn", "")
|
||||
kpp = counterparty.get("kpp", "")
|
||||
company_type = counterparty.get("companyType", "legal")
|
||||
|
||||
buyer_info = etree.SubElement(buyer, "ИдСв")
|
||||
|
||||
# Для ИП используем структуру СвИП
|
||||
if company_type == "entrepreneur":
|
||||
ogrnip = counterparty.get("ogrnip", "")
|
||||
|
||||
sv_ip = etree.SubElement(
|
||||
buyer_info, "СвИП",
|
||||
ИННФЛ=inn,
|
||||
СвГосРегИП=ogrnip
|
||||
)
|
||||
|
||||
# ФИО для ИП
|
||||
last_name = counterparty.get("legalLastName", "")
|
||||
first_name = counterparty.get("legalFirstName", "")
|
||||
middle_name = counterparty.get("legalMiddleName", "")
|
||||
|
||||
fio = etree.SubElement(sv_ip, "ФИО")
|
||||
fio.set("Фамилия", last_name)
|
||||
fio.set("Имя", first_name)
|
||||
if middle_name:
|
||||
fio.set("Отчество", middle_name)
|
||||
else:
|
||||
# Для юрлиц используем СвЮЛУч
|
||||
buyer_info.set("СвЮЛУч", inn)
|
||||
buyer_info.set("КПП", kpp if kpp else "")
|
||||
|
||||
# Наименование
|
||||
name = counterparty.get("name", "")
|
||||
if not name:
|
||||
name = counterparty.get("legalTitle", "")
|
||||
etree.SubElement(buyer_info, "НаимОрг", Наим=name)
|
||||
|
||||
# Адрес
|
||||
address = counterparty.get("legalAddress", "")
|
||||
if address:
|
||||
addr_element = etree.SubElement(buyer, "Адрес")
|
||||
etree.SubElement(
|
||||
addr_element, "АдрТекст",
|
||||
Текст=address
|
||||
)
|
||||
|
||||
# Банковские реквизиты
|
||||
if accounts and len(accounts) > 0:
|
||||
# Берём первый счёт с БИК
|
||||
for acc in accounts:
|
||||
if acc.get('bic'):
|
||||
bank_info = etree.SubElement(
|
||||
buyer, "БанкРекв",
|
||||
НомерСчета=acc.get('accountNumber', '')
|
||||
)
|
||||
etree.SubElement(
|
||||
bank_info, "СвБанк",
|
||||
НаимБанк=acc.get('bankName', ''),
|
||||
БИК=acc.get('bic', ''),
|
||||
КорСчет=acc.get('correspondentAccount', '')
|
||||
)
|
||||
break
|
||||
|
||||
# Контактная информация
|
||||
phone = counterparty.get("phone", "")
|
||||
email = counterparty.get("email", "")
|
||||
if phone or email:
|
||||
contact = etree.SubElement(buyer, "Контакт")
|
||||
if phone:
|
||||
etree.SubElement(contact, "Тлф").text = phone
|
||||
if email:
|
||||
etree.SubElement(contact, "ЭлПочта").text = email
|
||||
|
||||
def _add_positions_table(
|
||||
self,
|
||||
parent: etree.Element,
|
||||
positions: list
|
||||
) -> Dict[str, float]:
|
||||
"""
|
||||
Добавление таблицы товаров
|
||||
|
||||
Args:
|
||||
parent: Родительский XML элемент
|
||||
positions: Список позиций заказа
|
||||
|
||||
Returns:
|
||||
Словарь с итоговыми суммами
|
||||
"""
|
||||
table = etree.SubElement(parent, "ТаблСчФакт")
|
||||
|
||||
total_without_vat = 0
|
||||
total_vat = 0
|
||||
total_with_vat = 0
|
||||
|
||||
for index, pos in enumerate(positions, start=1):
|
||||
assortment = pos.get("assortment", {})
|
||||
|
||||
# Данные товара
|
||||
name = assortment.get("name", "Товар без наименования")
|
||||
quantity = pos.get("quantity", 0)
|
||||
# Цена из API уже включает НДС!
|
||||
price = pos.get("price", 0) / 100
|
||||
vat_rate = pos.get("vat", 20)
|
||||
|
||||
# Расчет сумм (цена из API уже включает НДС!)
|
||||
total_amount = quantity * price # Сумма С НДС
|
||||
# Извлекаем НДС из общей суммы
|
||||
amount = (total_amount / (1 + vat_rate / 100)
|
||||
if vat_rate else total_amount)
|
||||
vat_amount = total_amount - amount # Сумма НДС
|
||||
|
||||
total_without_vat += amount
|
||||
total_vat += vat_amount
|
||||
total_with_vat += total_amount
|
||||
|
||||
# Создание строки таблицы
|
||||
row = etree.SubElement(
|
||||
table, "СведТов",
|
||||
НомСтр=str(index)
|
||||
)
|
||||
|
||||
# Наименование товара
|
||||
etree.SubElement(row, "НаимТов", Наим=name)
|
||||
|
||||
# Единица измерения
|
||||
uom = assortment.get("uom", {})
|
||||
uom_name = uom.get("name", "шт") if uom else "шт"
|
||||
etree.SubElement(row, "ОКЕИ_Тов", НаимЕдИзм=uom_name)
|
||||
|
||||
# Количество
|
||||
etree.SubElement(row, "КолТов", Количество=str(quantity))
|
||||
|
||||
# Цена
|
||||
etree.SubElement(row, "ЦенаТов", Цена=f"{price:.2f}")
|
||||
|
||||
# Стоимость без НДС
|
||||
etree.SubElement(row, "СтТовБезНДС", СтоимБезНДС=f"{amount:.2f}")
|
||||
|
||||
# НДС
|
||||
etree.SubElement(
|
||||
row, "НалСт",
|
||||
СтавНДС=str(vat_rate),
|
||||
СумНДС=f"{vat_amount:.2f}"
|
||||
)
|
||||
|
||||
# Стоимость с НДС
|
||||
etree.SubElement(
|
||||
row, "СтТовУчНал",
|
||||
СтоимУчНДС=f"{total_amount:.2f}"
|
||||
)
|
||||
|
||||
# Итоговые суммы
|
||||
totals = etree.SubElement(table, "ВсегоОпл")
|
||||
etree.SubElement(
|
||||
totals, "СтТовБезНДСВсего",
|
||||
СтоимБезНДС=f"{total_without_vat:.2f}"
|
||||
)
|
||||
etree.SubElement(
|
||||
totals, "СумНДСВсего",
|
||||
СумНДС=f"{total_vat:.2f}"
|
||||
)
|
||||
etree.SubElement(
|
||||
totals, "СтТовУчНалВсего",
|
||||
СтоимУчНДС=f"{total_with_vat:.2f}"
|
||||
)
|
||||
|
||||
return {
|
||||
"without_vat": total_without_vat,
|
||||
"vat": total_vat,
|
||||
"with_vat": total_with_vat
|
||||
}
|
||||
|
||||
def generate_upd(
|
||||
self,
|
||||
order_data: Dict,
|
||||
output_path: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Генерация УПД (Универсальный передаточный документ)
|
||||
|
||||
Args:
|
||||
order_data: Полные данные заказа
|
||||
output_path: Путь для сохранения файла
|
||||
|
||||
Returns:
|
||||
XML в виде строки
|
||||
"""
|
||||
order = order_data["order"]
|
||||
organization = order_data["organization"]
|
||||
counterparty = order_data["counterparty"]
|
||||
positions = order_data["positions"]
|
||||
org_accounts = order_data.get("org_accounts", [])
|
||||
cp_accounts = order_data.get("cp_accounts", [])
|
||||
|
||||
# Корневой элемент
|
||||
root = etree.Element(
|
||||
"Файл",
|
||||
ВерсПрог=self.PROGRAM_VERSION,
|
||||
ВерсФорм=self.VERSION
|
||||
)
|
||||
|
||||
# Документ
|
||||
doc = etree.SubElement(root, "Документ")
|
||||
|
||||
# Сведения о счет-фактуре
|
||||
invoice_info = etree.SubElement(doc, "СвСчФакт")
|
||||
|
||||
# Номер и дата
|
||||
order_number = order.get("name", "б/н")
|
||||
order_date = self._format_date(order.get("moment", ""))
|
||||
|
||||
etree.SubElement(
|
||||
invoice_info, "НомерДата",
|
||||
Номер=order_number,
|
||||
Дата=order_date
|
||||
)
|
||||
|
||||
# Информация о продавце
|
||||
self._add_seller_info(invoice_info, organization, org_accounts)
|
||||
|
||||
# Грузоотправитель (он же продавец)
|
||||
gruz_ot = etree.SubElement(invoice_info, "ГрузОт")
|
||||
etree.SubElement(gruz_ot, "ОнЖе").text = "он же"
|
||||
|
||||
# Грузополучатель (покупатель)
|
||||
self._add_shipper_info(invoice_info, counterparty, cp_accounts)
|
||||
|
||||
# Документ подтверждающий отгрузку
|
||||
etree.SubElement(
|
||||
invoice_info, "ДокПодтвОтгрНом",
|
||||
РеквНаимДок="УПД",
|
||||
РеквНомерДок=order_number,
|
||||
РеквДатаДок=order_date
|
||||
)
|
||||
|
||||
# Информация о покупателе
|
||||
self._add_buyer_info(invoice_info, counterparty, cp_accounts)
|
||||
|
||||
# Валюта
|
||||
rate = order.get("rate", {})
|
||||
if rate and "currency" in rate:
|
||||
currency = rate["currency"]
|
||||
etree.SubElement(
|
||||
invoice_info, "ДенИзм",
|
||||
КодОКВ=currency.get("code", "643"),
|
||||
НаимОКВ=currency.get("fullName", "Российский рубль")
|
||||
)
|
||||
|
||||
# Таблица товаров
|
||||
totals = self._add_positions_table(doc, positions)
|
||||
|
||||
# Сведения о передаче товара
|
||||
sv_prod_per = etree.SubElement(doc, "СвПродПер")
|
||||
sv_per = etree.SubElement(
|
||||
sv_prod_per, "СвПер",
|
||||
СодОпер="Товары переданы",
|
||||
ДатаПер=order_date
|
||||
)
|
||||
# Без документа-основания передачи
|
||||
etree.SubElement(sv_per, "БезДокОснПер").text = "1"
|
||||
|
||||
# Подписант
|
||||
podpisant = etree.SubElement(doc, "Подписант", СпосПодтПолном="6")
|
||||
# Используем данные из организации
|
||||
if organization.get("companyType") == "entrepreneur":
|
||||
# Для ИП используем ФИО
|
||||
fio = etree.SubElement(podpisant, "ФИО")
|
||||
last_name = organization.get("legalLastName", "")
|
||||
first_name = organization.get("legalFirstName", "")
|
||||
middle_name = organization.get("legalMiddleName", "")
|
||||
if last_name:
|
||||
fio.set("Фамилия", last_name)
|
||||
if first_name:
|
||||
fio.set("Имя", first_name)
|
||||
if middle_name:
|
||||
fio.set("Отчество", middle_name)
|
||||
else:
|
||||
# Для ЮЛ можем указать директора (если данные есть)
|
||||
# Пока просто заглушка
|
||||
fio = etree.SubElement(podpisant, "ФИО")
|
||||
fio.set("Фамилия", "Подписант")
|
||||
fio.set("Имя", "Представитель")
|
||||
|
||||
# Конвертация в строку
|
||||
xml_string = etree.tostring(
|
||||
root,
|
||||
pretty_print=True,
|
||||
xml_declaration=True,
|
||||
encoding=self.encoding
|
||||
).decode(self.encoding)
|
||||
|
||||
# Сохранение в файл
|
||||
if output_path:
|
||||
with open(output_path, "w", encoding=self.encoding) as f:
|
||||
f.write(xml_string)
|
||||
logger.info(f"УПД сохранен: {output_path}")
|
||||
|
||||
return xml_string
|
||||
|
||||
def generate_tn(
|
||||
self,
|
||||
order_data: Dict,
|
||||
output_path: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Генерация ТН (Товарная накладная ТОРГ-12)
|
||||
|
||||
Args:
|
||||
order_data: Полные данные заказа
|
||||
output_path: Путь для сохранения файла
|
||||
|
||||
Returns:
|
||||
XML в виде строки
|
||||
"""
|
||||
order = order_data["order"]
|
||||
organization = order_data["organization"]
|
||||
counterparty = order_data["counterparty"]
|
||||
positions = order_data["positions"]
|
||||
|
||||
# Корневой элемент
|
||||
root = etree.Element(
|
||||
"Файл",
|
||||
ВерсПрог=self.PROGRAM_VERSION,
|
||||
ВерсФорм=self.VERSION,
|
||||
ТипДок="ТОРГ-12"
|
||||
)
|
||||
|
||||
# Документ
|
||||
doc = etree.SubElement(root, "Документ")
|
||||
|
||||
# Номер и дата
|
||||
order_number = order.get("name", "б/н")
|
||||
order_date = self._format_date(order.get("moment", ""))
|
||||
|
||||
doc_info = etree.SubElement(doc, "СвНакл")
|
||||
etree.SubElement(
|
||||
doc_info, "НомерДата",
|
||||
Номер=order_number,
|
||||
Дата=order_date
|
||||
)
|
||||
|
||||
# Информация о продавце
|
||||
self._add_seller_info(doc, organization)
|
||||
|
||||
# Информация о покупателе
|
||||
self._add_buyer_info(doc, counterparty)
|
||||
|
||||
# Таблица товаров
|
||||
totals = self._add_positions_table(doc, positions)
|
||||
|
||||
# Конвертация в строку
|
||||
xml_string = etree.tostring(
|
||||
root,
|
||||
pretty_print=True,
|
||||
xml_declaration=True,
|
||||
encoding=self.encoding
|
||||
).decode(self.encoding)
|
||||
|
||||
# Сохранение в файл
|
||||
if output_path:
|
||||
with open(output_path, "w", encoding=self.encoding) as f:
|
||||
f.write(xml_string)
|
||||
logger.info(f"ТН сохранена: {output_path}")
|
||||
|
||||
return xml_string
|
||||
|
||||
|
||||
# Пример использования
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
# Тестовые данные
|
||||
test_order = {
|
||||
"order": {
|
||||
"name": "00001",
|
||||
"moment": "2024-01-15T10:00:00Z",
|
||||
"sum": 120000
|
||||
},
|
||||
"organization": {
|
||||
"name": "ООО Тестовая организация",
|
||||
"inn": "7707083893",
|
||||
"kpp": "770701001",
|
||||
"legalAddress": "123456, г. Москва, ул. Тестовая, д. 1"
|
||||
},
|
||||
"counterparty": {
|
||||
"name": "ООО Покупатель",
|
||||
"inn": "7707083894",
|
||||
"kpp": "770701002",
|
||||
"legalAddress": "654321, г. Москва, ул. Другая, д. 2"
|
||||
},
|
||||
"positions": [
|
||||
{
|
||||
"assortment": {"name": "Товар 1", "uom": {"name": "шт"}},
|
||||
"quantity": 10,
|
||||
"price": 10000,
|
||||
"vat": 20
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
generator = XMLGenerator()
|
||||
|
||||
# Генерация УПД
|
||||
upd_xml = generator.generate_upd(test_order, "test_upd.xml")
|
||||
print("УПД создан")
|
||||
|
||||
# Генерация ТН
|
||||
tn_xml = generator.generate_tn(test_order, "test_tn.xml")
|
||||
print("ТН создана")
|
||||
28
requirements.txt
Normal file
28
requirements.txt
Normal file
@ -0,0 +1,28 @@
|
||||
# Telegram Bot
|
||||
python-telegram-bot==20.7
|
||||
|
||||
# HTTP клиент
|
||||
requests==2.31.0
|
||||
|
||||
# Валидация данных
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
|
||||
# Excel генерация
|
||||
openpyxl==3.1.2
|
||||
xlsxwriter==3.1.9
|
||||
|
||||
# XML обработка
|
||||
lxml==4.9.3
|
||||
|
||||
# Переменные окружения
|
||||
python-dotenv==1.0.0
|
||||
|
||||
# Утилиты
|
||||
python-dateutil==2.8.2
|
||||
|
||||
# Логирование
|
||||
loguru==0.7.2
|
||||
|
||||
# Шифрование (для хранения учетных данных пользователей)
|
||||
cryptography==41.0.7
|
||||
Loading…
x
Reference in New Issue
Block a user