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:
admin i 2025-11-03 14:19:48 +03:00
commit 5b6ab02e53
9 changed files with 3458 additions and 0 deletions

67
.gitignore vendored Normal file
View 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/

1105
bot.py Normal file

File diff suppressed because it is too large Load Diff

62
config.py Normal file
View 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
View File

@ -0,0 +1,3 @@
"""Пакет модулей бота для бухгалтеров"""
__version__ = '1.0.0'

809
modules/moysklad_client.py Normal file
View 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
View 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
View 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
View 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
View 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