From 5b6ab02e53f3117bf36a629c5e6fb34a72100ff5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Mon, 3 Nov 2025 14:19:48 +0300 Subject: [PATCH] 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 --- .gitignore | 67 +++ bot.py | 1105 ++++++++++++++++++++++++++++++++++++ config.py | 62 ++ modules/__init__.py | 3 + modules/moysklad_client.py | 809 ++++++++++++++++++++++++++ modules/user_manager.py | 205 +++++++ modules/validator.py | 513 +++++++++++++++++ modules/xml_generator.py | 666 ++++++++++++++++++++++ requirements.txt | 28 + 9 files changed, 3458 insertions(+) create mode 100644 .gitignore create mode 100644 bot.py create mode 100644 config.py create mode 100644 modules/__init__.py create mode 100644 modules/moysklad_client.py create mode 100644 modules/user_manager.py create mode 100644 modules/validator.py create mode 100644 modules/xml_generator.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b946769 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..e681a35 --- /dev/null +++ b/bot.py @@ -0,0 +1,1105 @@ +""" +Telegram бот для работы с отгрузками МойСклад +Функции: +- Поиск отгрузок (по номеру заказа или отгрузки) +- Пагинация списка отгрузок +- Валидация данных отгрузки +- Генерация документов (XML/Excel) +- Выбор печатных форм из МойСклад +""" +import os +import sys +import logging +from pathlib import Path + +from telegram import ( + Update, + ReplyKeyboardMarkup, + InlineKeyboardButton, + InlineKeyboardMarkup +) +from telegram.ext import ( + Application, + CommandHandler, + MessageHandler, + CallbackQueryHandler, + ContextTypes, + ConversationHandler, + filters +) + +# Добавляем путь к modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'modules')) + +from moysklad_client import MoySkladClient # noqa: E402 +from validator import OrderValidator # noqa: E402 +from xml_generator import XMLGenerator # noqa: E402 +from user_manager import UserManager # noqa: E402 +from config import settings # noqa: E402 + +# Настройка логирования +logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + level=logging.INFO +) +logger = logging.getLogger(__name__) + +# Состояния разговора +( + AUTH_LOGIN, + AUTH_PASSWORD, + MAIN_MENU, + SEARCH_ORDER, + SHOW_ORDERS, + SELECT_ORDER, + VALIDATE_ORDER, + SELECT_FORMAT, + SELECT_TEMPLATE, + DOWNLOAD_DOCUMENT +) = range(10) + +# Менеджер пользователей +user_manager = UserManager() + + +def get_moysklad_client(user_id: int) -> MoySkladClient: + """ + Получает клиента МойСклад для конкретного пользователя + + Args: + user_id: ID пользователя Telegram + + Returns: + MoySkladClient с учетными данными пользователя + """ + credentials = user_manager.get_user_credentials(user_id) + if not credentials: + raise ValueError(f"Пользователь {user_id} не авторизован") + + return MoySkladClient( + credentials['login'], + credentials['password'], + settings.moysklad_api_url + ) + + +def get_main_keyboard(): + """Возвращает основную клавиатуру""" + keyboard = [ + ["🔍 Поиск отгрузки", "📋 Список отгрузок"], + ["❓ Помощь"] + ] + return ReplyKeyboardMarkup(keyboard, resize_keyboard=True) + + +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработчик команды /start""" + user = update.effective_user + user_id = user.id + + # Проверяем, авторизован ли пользователь + if not user_manager.is_user_registered(user_id): + await update.message.reply_text( + f"Привет, {user.first_name}! 👋\n\n" + f"Для начала работы необходимо авторизоваться в МойСклад.\n\n" + f"Пожалуйста, введите ваш логин от МойСклад:", + parse_mode='HTML' + ) + return AUTH_LOGIN + + # Пользователь авторизован - показываем главное меню + await update.message.reply_text( + f"С возвращением, {user.first_name}! 👋\n\n" + f"Выберите действие:", + reply_markup=get_main_keyboard() + ) + + # Очищаем данные пользователя + context.user_data.clear() + + return MAIN_MENU + + +async def auth_login(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработчик ввода логина""" + login = update.message.text.strip() + + # Сохраняем логин временно + context.user_data['temp_login'] = login + + await update.message.reply_text( + "✅ Логин принят.\n\n" + "Теперь введите ваш пароль от МойСклад:\n\n" + "⚠️ Пароль будет зашифрован и сохранён безопасно.", + parse_mode='HTML' + ) + + return AUTH_PASSWORD + + +async def auth_password(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработчик ввода пароля и завершение авторизации""" + user = update.effective_user + user_id = user.id + password = update.message.text.strip() + login = context.user_data.get('temp_login') + + # Удаляем сообщение с паролем для безопасности + try: + await update.message.delete() + except Exception: + pass + + if not login: + await update.message.reply_text( + "❌ Ошибка: логин не найден. Начните заново с команды /start" + ) + return ConversationHandler.END + + # Проверяем учетные данные + await update.message.reply_text("⏳ Проверяю учетные данные...") + + try: + # Создаём временный клиент для проверки + test_client = MoySkladClient( + login, password, settings.moysklad_api_url + ) + + # Пробуем получить данные для проверки авторизации + result = test_client.get_orders(limit=1) + + if result and 'rows' in result: + # Авторизация успешна - сохраняем пользователя + success = user_manager.register_user( + user_id, + login, + password, + user.username + ) + + if success: + # Очищаем временные данные + context.user_data.pop('temp_login', None) + + await update.message.reply_text( + "✅ Авторизация успешна!\n\n" + "Ваши учетные данные сохранены и зашифрованы.\n\n" + "Выберите действие:", + parse_mode='HTML', + reply_markup=get_main_keyboard() + ) + + logger.info(f"Пользователь {user_id} успешно авторизован") + return MAIN_MENU + else: + await update.message.reply_text( + "❌ Ошибка сохранения данных. " + "Попробуйте позже или обратитесь к администратору." + ) + return ConversationHandler.END + else: + await update.message.reply_text( + "❌ Ошибка авторизации\n\n" + "Неверный логин или пароль. Попробуйте снова командой /start", + parse_mode='HTML' + ) + context.user_data.pop('temp_login', None) + return ConversationHandler.END + + except Exception as e: + logger.error(f"Ошибка авторизации пользователя {user_id}: {e}") + await update.message.reply_text( + "❌ Ошибка авторизации\n\n" + f"Не удалось подключиться к МойСклад.\n" + f"Проверьте правильность логина и пароля.\n\n" + f"Детали: {str(e)[:100]}", + parse_mode='HTML' + ) + context.user_data.pop('temp_login', None) + return ConversationHandler.END + + +async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Показывает помощь""" + help_text = ( + "📖 Инструкция по использованию\n\n" + "🔍 Поиск отгрузки\n" + "Введите номер заказа или отгрузки для поиска.\n" + "Например: 951 или 00001\n\n" + "📋 Список отгрузок\n" + "Показывает последние отгрузки с пагинацией.\n" + "Используйте кнопки для навигации.\n\n" + "🔐 Учетные данные\n" + "/logout - Выйти из аккаунта МойСклад\n" + "/change_credentials - Сменить логин/пароль\n\n" + "✅ Валидация\n" + "После выбора отгрузки бот автоматически проверит:\n" + "- Заполненность обязательных полей\n" + "- Корректность ИНН/КПП\n" + "- Соответствие сумм\n\n" + "📄 Генерация документов\n" + "После успешной валидации можно:\n" + "- Скачать XML для ЭДО\n" + "- Сгенерировать Excel (УПД, ТН и др.)" + ) + + await update.message.reply_text( + help_text, + parse_mode='HTML', + reply_markup=get_main_keyboard() + ) + + +async def logout_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Выход из аккаунта МойСклад""" + user_id = update.effective_user.id + + if user_manager.delete_user(user_id): + await update.message.reply_text( + "✅ Вы вышли из аккаунта.\n\n" + "Ваши учетные данные удалены.\n\n" + "Используйте /start для новой авторизации." + ) + logger.info(f"Пользователь {user_id} вышел из аккаунта") + else: + await update.message.reply_text( + "❌ Вы не были авторизованы." + ) + + return ConversationHandler.END + + +async def change_credentials_command( + update: Update, + context: ContextTypes.DEFAULT_TYPE +): + """Смена учетных данных""" + user_id = update.effective_user.id + + if not user_manager.is_user_registered(user_id): + await update.message.reply_text( + "❌ Вы не авторизованы. Используйте /start" + ) + return ConversationHandler.END + + # Удаляем старые данные + user_manager.delete_user(user_id) + + await update.message.reply_text( + "🔄 Смена учетных данных\n\n" + "Введите новый логин от МойСклад:", + parse_mode='HTML' + ) + + return AUTH_LOGIN + + return MAIN_MENU + + +async def search_order_prompt(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Запрашивает номер заказа для поиска""" + await update.message.reply_text( + "🔍 Введите номер заказа или отгрузки:\n\n" + "Например: 951, 00001 или полный UUID", + reply_markup=ReplyKeyboardMarkup( + [["⬅️ Назад"]], + resize_keyboard=True + ) + ) + + return SEARCH_ORDER + + +async def search_order(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Ищет заказ по номеру""" + user_id = update.effective_user.id + query = update.message.text.strip() + + if query == "⬅️ Назад": + return await start(update, context) + + # Показываем индикатор загрузки + msg = await update.message.reply_text("🔄 Поиск...") + + try: + # Получаем клиента для пользователя + moysklad_client = get_moysklad_client(user_id) + + # Ищем через API + search_result = moysklad_client.search_orders(query, limit=10) + orders = search_result.get("rows", []) + + if not orders: + await msg.edit_text( + "❌ Отгрузки не найдены.\n\n" + "Попробуйте другой запрос:" + ) + await update.message.reply_text( + "Введите номер заказа или отгрузки:", + reply_markup=ReplyKeyboardMarkup( + [["⬅️ Назад"]], + resize_keyboard=True + ) + ) + return SEARCH_ORDER + + # Сохраняем результаты + context.user_data['orders'] = orders + context.user_data['page'] = 0 + + # Показываем результаты + await msg.delete() + await show_orders_page(update, context) + + return SELECT_ORDER + + except Exception as e: + logger.error(f"Ошибка поиска: {e}") + await msg.edit_text( + f"❌ Ошибка поиска: {str(e)}\n\n" + f"Попробуйте снова:" + ) + await update.message.reply_text( + "Введите номер заказа или отгрузки:", + reply_markup=ReplyKeyboardMarkup( + [["⬅️ Назад"]], + resize_keyboard=True + ) + ) + return SEARCH_ORDER + + +async def show_orders_list(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Показывает список последних отгрузок""" + user_id = update.effective_user.id + msg = await update.message.reply_text("🔄 Загрузка списка...") + + try: + # Получаем клиента для пользователя + moysklad_client = get_moysklad_client(user_id) + + # Получаем последние отгрузки + result = moysklad_client.get_orders(limit=25, offset=0) + orders = result.get("rows", []) + + if not orders: + await msg.edit_text( + "❌ Отгрузки не найдены.", + reply_markup=get_main_keyboard() + ) + return MAIN_MENU + + # Сохраняем результаты + context.user_data['orders'] = orders + context.user_data['page'] = 0 + context.user_data['total'] = result.get("meta", {}).get("size", len(orders)) + + # Показываем первую страницу + await msg.delete() + await show_orders_page(update, context) + + return SELECT_ORDER + + except Exception as e: + logger.error(f"Ошибка получения списка: {e}") + await msg.edit_text( + f"❌ Ошибка: {str(e)}", + reply_markup=get_main_keyboard() + ) + return MAIN_MENU + + +async def show_orders_page(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Показывает страницу с отгрузками""" + orders = context.user_data.get('orders', []) + page = context.user_data.get('page', 0) + + page_size = 5 + start = page * page_size + end = start + page_size + page_orders = orders[start:end] + + if not page_orders: + await update.message.reply_text( + "❌ Нет отгрузок на этой странице.", + reply_markup=get_main_keyboard() + ) + return MAIN_MENU + + # Формируем текст со списком + text = f"📋 Отгрузки (стр. {page + 1})\n\n" + + # Создаём inline кнопки для каждой отгрузки + keyboard = [] + + for i, order in enumerate(page_orders, start=start+1): + order_name = order.get('name', 'N/A') + order_date = order.get('moment', '')[:10] # YYYY-MM-DD + sum_val = order.get('sum', 0) / 100 # Копейки в рубли + + agent = order.get('agent', {}) + agent_name = agent.get('name', 'N/A') + + text += f"{i}. №{order_name} от {order_date}\n" + text += f" Контрагент: {agent_name}\n" + text += f" Сумма: {sum_val:,.2f} ₽\n\n" + + # Кнопка выбора + order_id = order['meta']['href'].split('/')[-1].split('?')[0] + keyboard.append([ + InlineKeyboardButton( + f"✅ Выбрать №{order_name}", + callback_data=f"select:{order_id}" + ) + ]) + + # Кнопки навигации + nav_buttons = [] + if page > 0: + nav_buttons.append( + InlineKeyboardButton("⬅️ Назад", callback_data="page:prev") + ) + if end < len(orders): + nav_buttons.append( + InlineKeyboardButton("➡️ Вперёд", callback_data="page:next") + ) + + if nav_buttons: + keyboard.append(nav_buttons) + + # Кнопка отмены + keyboard.append([ + InlineKeyboardButton("❌ Отмена", callback_data="cancel") + ]) + + reply_markup = InlineKeyboardMarkup(keyboard) + + # Отправляем или редактируем сообщение + if update.callback_query: + await update.callback_query.edit_message_text( + text, + parse_mode='HTML', + reply_markup=reply_markup + ) + else: + await update.message.reply_text( + text, + parse_mode='HTML', + reply_markup=reply_markup + ) + + return SELECT_ORDER + + +async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработчик inline кнопок""" + query = update.callback_query + await query.answer() + + data = query.data + + if data == "cancel": + await query.edit_message_text( + "❌ Отменено.", + reply_markup=None + ) + await query.message.reply_text( + "Выберите действие:", + reply_markup=get_main_keyboard() + ) + return MAIN_MENU + + elif data.startswith("page:"): + # Пагинация + direction = data.split(":")[1] + page = context.user_data.get('page', 0) + + if direction == "prev": + context.user_data['page'] = max(0, page - 1) + else: # next + context.user_data['page'] = page + 1 + + await show_orders_page(update, context) + return SELECT_ORDER + + elif data.startswith("select:"): + # Выбрана отгрузка + order_id = data.split(":")[1] + context.user_data['selected_order_id'] = order_id + + await query.edit_message_text( + "🔄 Загрузка данных отгрузки...", + reply_markup=None + ) + + # Переходим к валидации + return await validate_order(update, context) + + elif data.startswith("format:"): + # Выбран формат документа + format_type = data.split(":")[1] + context.user_data['format'] = format_type + + if format_type == "xml": + # XML - сразу генерируем + return await generate_xml(update, context) + else: + # Excel - сбрасываем флаг и показываем шаблоны + context.user_data['show_all_templates'] = False + return await show_templates(update, context) + + elif data.startswith("template:"): + # Выбран шаблон + template_id = data.split(":")[1] + context.user_data['template_id'] = template_id + + return await generate_excel(update, context) + + elif data == "show_all_templates": + # Показать все шаблоны + context.user_data['show_all_templates'] = True + return await show_templates(update, context) + + return SELECT_ORDER + + +async def validate_order(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Валидация отгрузки""" + user_id = update.effective_user.id + order_id = context.user_data.get('selected_order_id') + + try: + # Получаем клиента для пользователя + moysklad_client = get_moysklad_client(user_id) + + # Получаем полные данные + order_data = moysklad_client.get_full_order_data(order_id) + context.user_data['order_data'] = order_data + + # Проводим валидацию + validator = OrderValidator() + + # Собираем данные для валидации + validation_data = { + 'order': order_data['order'], + 'organization': order_data['organization'], + 'counterparty': order_data['counterparty'], + 'positions': order_data['positions'] + } + + # Валидируем заказ + validation_results = validator.validate_order(validation_data) + + order = order_data['order'] + org = order_data['organization'] + counterparty = order_data['counterparty'] + positions = order_data['positions'] + + # Проверяем наличие критичных ошибок + has_critical = any( + r.level.name == "CRITICAL" + for r in validation_results + ) + has_warnings = any( + r.level.name == "WARNING" + for r in validation_results + ) + + # Формируем текст с результатами валидации + if has_critical: + status_icon = "❌" + status_text = "Валидация не пройдена!" + elif has_warnings: + status_icon = "⚠️" + status_text = "Валидация выявила предупреждения" + else: + status_icon = "✅" + status_text = "Валидация пройдена успешно!" + + validation_text = ( + f"{status_icon} {status_text}\n\n" + f"Отгрузка №{order.get('name')}\n" + f"Дата: {order.get('moment', '')[:10]}\n" + f"Сумма: {order.get('sum', 0) / 100:,.2f} ₽\n\n" + f"Продавец: {org.get('name')}\n" + f"ИНН: {org.get('inn')}\n\n" + f"Покупатель: {counterparty.get('name')}\n" + f"ИНН: {counterparty.get('inn')}\n\n" + ) + + # Добавляем информацию о грузополучателе если есть + # Если грузополучатель не указан явно, используем контрагента + consignee = order_data.get('consignee') + if not consignee: + consignee = counterparty # Используем контрагента + + if consignee: + consignee_name = consignee.get('name', 'Не указан') + consignee_inn = consignee.get('inn', 'Не указан') + validation_text += ( + f"Грузополучатель: {consignee_name}\n" + f"ИНН: {consignee_inn}\n\n" + ) + + validation_text += f"Товаров: {len(positions)} шт.\n" + + # Добавляем результаты валидации + if validation_results: + validation_text += "\nРезультаты проверки:\n" + for result in validation_results[:5]: # Показываем первые 5 + level_icon = { + "VALID": "✅", + "WARNING": "⚠️", + "CRITICAL": "❌" + }.get(result.level.name, "ℹ️") + + validation_text += f"{level_icon} {result.message}\n" + + if len(validation_results) > 5: + remaining = len(validation_results) - 5 + validation_text += f"\n... и ещё {remaining}\n" + + validation_text += "\nВыберите формат документа:" + + # Формируем клавиатуру - блокируем генерацию при критических ошибках + if has_critical: + keyboard = [ + [InlineKeyboardButton( + "🔒 Генерация документов заблокирована", + callback_data="blocked" + )], + [InlineKeyboardButton("❌ Отмена", callback_data="cancel")] + ] + else: + keyboard = [ + [InlineKeyboardButton( + "🧾 XML", + callback_data="format:xml" + )], + [InlineKeyboardButton( + "📊 Excel", + callback_data="format:excel" + )], + [InlineKeyboardButton("❌ Отмена", callback_data="cancel")] + ] + + if update.callback_query: + await update.callback_query.edit_message_text( + validation_text, + parse_mode='HTML', + reply_markup=InlineKeyboardMarkup(keyboard) + ) + else: + await update.message.reply_text( + validation_text, + parse_mode='HTML', + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + return SELECT_FORMAT + + except Exception as e: + logger.error(f"Ошибка валидации: {e}") + + error_text = f"❌ Ошибка валидации:\n{str(e)}" + + if update.callback_query: + await update.callback_query.edit_message_text(error_text) + else: + await update.message.reply_text(error_text) + + await context.bot.send_message( + chat_id=update.effective_chat.id, + text="Выберите действие:", + reply_markup=get_main_keyboard() + ) + + return MAIN_MENU + + +async def generate_xml(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Генерация XML документа""" + user_id = update.effective_user.id + query = update.callback_query + + await query.edit_message_text( + "🔄 Генерация XML документа...", + reply_markup=None + ) + + try: + order_id = context.user_data.get('selected_order_id') + + if not order_id: + raise ValueError("ID отгрузки не найден") + + # Получаем клиента для пользователя + moysklad_client = get_moysklad_client(user_id) + + # Получаем полные данные отгрузки + order_data = moysklad_client.get_full_order_data(order_id) + + # Создаём генератор XML + xml_gen = XMLGenerator() + + # Создаём временную директорию + output_dir = Path("temp_documents") + output_dir.mkdir(exist_ok=True) + + # Генерируем XML файл + filename = f"{order_data.get('name', order_id)}.xml" + output_path = str(output_dir / filename) + xml_gen.generate_upd(order_data, output_path) + + # Отправляем файл + await query.message.reply_document( + document=open(output_path, 'rb'), + caption="✅ XML документ (УПД 5.03) успешно создан!", + reply_markup=get_main_keyboard() + ) + + # Удаляем временный файл + os.remove(output_path) + + # Очищаем данные пользователя + context.user_data.clear() + + await query.message.reply_text( + "Выберите действие:", + reply_markup=get_main_keyboard() + ) + + return MAIN_MENU + + except Exception as e: + logger.error(f"Ошибка генерации XML: {e}") + + await query.message.reply_text( + f"❌ Ошибка генерации XML: {str(e)}\n\n" + f"Выберите действие:", + reply_markup=get_main_keyboard() + ) + + context.user_data.clear() + return MAIN_MENU + + +async def show_templates(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Показывает рекомендованный шаблон или список всех шаблонов""" + user_id = update.effective_user.id + query = update.callback_query + + # Проверяем, запрошены ли все шаблоны + show_all = context.user_data.get('show_all_templates', False) + + await query.edit_message_text( + "🔄 Загрузка шаблонов...", + reply_markup=None + ) + + try: + # Получаем клиента для пользователя + moysklad_client = get_moysklad_client(user_id) + + # Получаем данные заказа для определения типа клиента + order_data = context.user_data.get('order_data') + counterparty = order_data.get('counterparty') if order_data else None + + # Определяем тип клиента + # ИП: ИНН 12 цифр + (есть КПП ИЛИ "ИП" в названии) → УПД + # Юр.лицо: ИНН 10 цифр → УПД + # Физ.лицо: ИНН 12 цифр без КПП и без "ИП" в названии → ТН + is_individual = True # По умолчанию физ.лицо (ТН) + if counterparty: + inn = counterparty.get('inn', '') + kpp = counterparty.get('kpp', '') + name = counterparty.get('name', '').upper() + + if len(inn) == 10: + # Юр.лицо (ИНН 10 цифр) → УПД + is_individual = False + elif len(inn) == 12: + # ИНН 12 цифр: проверяем КПП или "ИП" в названии + if kpp or 'ИП ' in name or name.startswith('ИП'): + # ИП → УПД + is_individual = False + # Иначе: физ.лицо → ТН + + # Получаем все шаблоны (customtemplate + embeddedtemplate) + custom_templates = moysklad_client.get_templates( + "demand", "customtemplate" + ) + embedded_templates = moysklad_client.get_templates( + "demand", "embeddedtemplate" + ) + templates = custom_templates + embedded_templates + + if not templates: + await query.message.reply_text( + "❌ Шаблоны не найдены.\n\n" + "Выберите действие:", + reply_markup=get_main_keyboard() + ) + context.user_data.clear() + return MAIN_MENU + + # Определяем рекомендованный шаблон по конкретному ID + recommended_template = None + + # ID шаблонов по умолчанию + TN_DEFAULT_ID = "92743bbb-c8c8-42e2-8686-350eace81edd" # ТН НДС + UPD_DEFAULT_ID = "be6d441a-5982-48fe-ac21-46754e051f8c" # УПД тест + + # Выбираем нужный ID в зависимости от типа клиента + default_id = TN_DEFAULT_ID if is_individual else UPD_DEFAULT_ID + + # Ищем шаблон с нужным ID + for tpl in templates: + if tpl.get('id') == default_id: + recommended_template = tpl + break + + # Если не нашли по ID - берём первый + if not recommended_template and templates: + recommended_template = templates[0] + + keyboard = [] + + if show_all: + # Показываем все шаблоны + text = "📄 Все доступные шаблоны:\n\n" + + for i, tpl in enumerate(templates, 1): + tpl_name = tpl.get('name', 'N/A') + tpl_id = tpl.get('id') + + # Отмечаем рекомендованный + if tpl == recommended_template: + text += f"{i}. ⭐ {tpl_name} (рекомендовано)\n" + else: + text += f"{i}. {tpl_name}\n" + + keyboard.append([ + InlineKeyboardButton( + (f"⭐ {tpl_name}" + if tpl == recommended_template + else tpl_name), + callback_data=f"template:{tpl_id}" + ) + ]) + + keyboard.append([ + InlineKeyboardButton("❌ Отмена", callback_data="cancel") + ]) + else: + # Показываем только рекомендованный шаблон + client_type = "физ. лица" if is_individual else "юр. лица/ИП" + tpl_name = recommended_template.get('name', 'N/A') + tpl_id = recommended_template.get('id') + + text = ( + f"📄 Рекомендованный шаблон\n\n" + f"Тип клиента: {client_type}\n" + f"Шаблон: {tpl_name}\n\n" + f"Используйте рекомендованный шаблон или выберите другой:" + ) + + keyboard = [ + [InlineKeyboardButton( + f"✅ {tpl_name}", + callback_data=f"template:{tpl_id}" + )], + [InlineKeyboardButton( + "📋 Показать все шаблоны", + callback_data="show_all_templates" + )], + [InlineKeyboardButton("❌ Отмена", callback_data="cancel")] + ] + + await query.edit_message_text( + text, + parse_mode='HTML', + reply_markup=InlineKeyboardMarkup(keyboard) + ) + + return SELECT_TEMPLATE + + except Exception as e: + logger.error(f"Ошибка получения шаблонов: {e}") + + await query.message.reply_text( + f"❌ Ошибка: {str(e)}\n\n" + f"Выберите действие:", + reply_markup=get_main_keyboard() + ) + + context.user_data.clear() + return MAIN_MENU + + +async def generate_excel(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Генерация Excel документа""" + user_id = update.effective_user.id + query = update.callback_query + + await query.edit_message_text( + "🔄 Генерация документа...", + reply_markup=None + ) + + try: + # Получаем клиента для пользователя + moysklad_client = get_moysklad_client(user_id) + + order_id = context.user_data.get('selected_order_id') + template_id = context.user_data.get('template_id') + template_type = context.user_data.get( + 'template_type', 'customtemplate' + ) + + # Создаём временную директорию если нужна + output_dir = Path("temp_documents") + output_dir.mkdir(exist_ok=True) + + # Генерируем документ + output_path = moysklad_client.print_document( + entity_type="demand", + entity_id=order_id, + template_id=template_id, + template_type=template_type, + extension="xls", + output_path=str(output_dir / f"{order_id}.xls") + ) + + # Отправляем файл + await query.message.reply_document( + document=open(output_path, 'rb'), + caption="✅ Документ успешно создан!", + reply_markup=get_main_keyboard() + ) + + # Удаляем временный файл + os.remove(output_path) + + # Очищаем данные пользователя + context.user_data.clear() + + await query.message.reply_text( + "Выберите действие:", + reply_markup=get_main_keyboard() + ) + + return MAIN_MENU + + except Exception as e: + logger.error(f"Ошибка генерации документа: {e}") + + await query.message.reply_text( + f"❌ Ошибка: {str(e)}\n\n" + f"Выберите действие:", + reply_markup=get_main_keyboard() + ) + + context.user_data.clear() + return MAIN_MENU + + +async def handle_text(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Обработчик текстовых сообщений""" + text = update.message.text + + if text == "🔍 Поиск отгрузки": + return await search_order_prompt(update, context) + + elif text == "📋 Список отгрузок": + return await show_orders_list(update, context) + + elif text == "❓ Помощь": + return await help_command(update, context) + + elif text == "⬅️ Назад": + return await start(update, context) + + else: + await update.message.reply_text( + "❌ Неизвестная команда.\n" + "Используйте кнопки меню.", + reply_markup=get_main_keyboard() + ) + return MAIN_MENU + + +def main(): + """Запуск бота""" + # Создаём приложение + application = Application.builder().token( + settings.telegram_bot_token + ).build() + + # Обработчики + application.add_handler(CommandHandler("start", start)) + application.add_handler(CommandHandler("help", help_command)) + application.add_handler(CommandHandler("logout", logout_command)) + application.add_handler( + CommandHandler("change_credentials", change_credentials_command) + ) + + # Обработчик состояний + conv_handler = ConversationHandler( + entry_points=[ + MessageHandler( + filters.Regex("^🔍 Поиск отгрузки$"), + search_order_prompt + ), + MessageHandler( + filters.Regex("^📋 Список отгрузок$"), + show_orders_list + ), + MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text), + ], + states={ + AUTH_LOGIN: [ + MessageHandler( + filters.TEXT & ~filters.COMMAND, + auth_login + ), + ], + AUTH_PASSWORD: [ + MessageHandler( + filters.TEXT & ~filters.COMMAND, + auth_password + ), + ], + MAIN_MENU: [ + MessageHandler(filters.TEXT & ~filters.COMMAND, handle_text), + ], + SEARCH_ORDER: [ + MessageHandler( + filters.TEXT & ~filters.COMMAND, + search_order + ), + ], + SELECT_ORDER: [ + CallbackQueryHandler(handle_callback), + ], + SELECT_FORMAT: [ + CallbackQueryHandler(handle_callback), + ], + SELECT_TEMPLATE: [ + CallbackQueryHandler(handle_callback), + ], + }, + fallbacks=[ + CommandHandler("start", start), + ], + ) + + application.add_handler(conv_handler) + + # Запускаем бота + logger.info("🤖 Бот запущен!") + application.run_polling(allowed_updates=Update.ALL_TYPES) + + +if __name__ == "__main__": + main() diff --git a/config.py b/config.py new file mode 100644 index 0000000..0b9275c --- /dev/null +++ b/config.py @@ -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 diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..5b09001 --- /dev/null +++ b/modules/__init__.py @@ -0,0 +1,3 @@ +"""Пакет модулей бота для бухгалтеров""" + +__version__ = '1.0.0' diff --git a/modules/moysklad_client.py b/modules/moysklad_client.py new file mode 100644 index 0000000..f0d6fdf --- /dev/null +++ b/modules/moysklad_client.py @@ -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]}") diff --git a/modules/user_manager.py b/modules/user_manager.py new file mode 100644 index 0000000..285daf3 --- /dev/null +++ b/modules/user_manager.py @@ -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) diff --git a/modules/validator.py b/modules/validator.py new file mode 100644 index 0000000..ac78330 --- /dev/null +++ b/modules/validator.py @@ -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 '❌ некорректен'}") diff --git a/modules/xml_generator.py b/modules/xml_generator.py new file mode 100644 index 0000000..3cc9e2a --- /dev/null +++ b/modules/xml_generator.py @@ -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("ТН создана") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..94e7b8d --- /dev/null +++ b/requirements.txt @@ -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