From 29dd08ab16657ad0b4a3a041148919afa3712630 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: Fri, 7 Nov 2025 08:24:53 +0300 Subject: [PATCH] =?UTF-8?q?docs:=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20.env.example=20=D0=B4=D0=BB=D1=8F=20INCLUDE=5FAC?= =?UTF-8?q?COUNTING=5FACCOUNTS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + .env.prod.example | 1 + INSTALL_INFO.txt | 72 ++ LICENSE.txt | 21 + app_icon.ico | Bin 0 -> 29885 bytes app_icon.png | Bin 0 -> 10377 bytes build-image.bat | 60 -- build-image.sh | 82 --- build_gui.bat | 82 +++ build_gui.sh | 75 +++ config.py | 30 +- create_icon.py | 68 ++ deploy-prod.bat | 75 --- docs/BATCH_VALIDATION.md | 316 +++++++++ docs/CONTRACT_UUID_FIX.md | 146 ---- docs/DOCKER_CHANGELOG.md | 226 ------- docs/DOCKER_GUIDE.md | 493 -------------- docs/DOCKER_QUICKSTART.md | 153 ----- docs/PRODUCTION_DEPLOYMENT.md | 436 ------------ docs/XML_1C_FORMAT.md | 195 ------ docs/next_steps.md | 284 -------- docs/validator_refactoring.md | 219 ------ docs/xml_fixes_applied.md | 296 -------- docs/xml_generator_analysis.md | 395 ----------- gui_app.py | 964 +++++++++++++++++++++++++++ gui_app.spec | 75 +++ installer.iss | 83 +++ modules/validator.py | 2 +- modules/xml_generator.py | 429 +++++++----- modules/xml_generator_refactored2.py | 446 +++++++++++++ requirements_gui.txt | 33 + start.bat | 47 -- start.sh | 44 -- start_bot.bat | 27 - start_bot.sh | 34 - stop.bat | 16 - stop.sh | 13 - test_final.xml | 144 ---- test_fixed.xml | 139 ---- test_order_4926.xml | 89 --- 40 files changed, 2531 insertions(+), 3780 deletions(-) create mode 100644 INSTALL_INFO.txt create mode 100644 LICENSE.txt create mode 100644 app_icon.ico create mode 100644 app_icon.png delete mode 100644 build-image.bat delete mode 100644 build-image.sh create mode 100644 build_gui.bat create mode 100644 build_gui.sh create mode 100644 create_icon.py delete mode 100644 deploy-prod.bat create mode 100644 docs/BATCH_VALIDATION.md delete mode 100644 docs/CONTRACT_UUID_FIX.md delete mode 100644 docs/DOCKER_CHANGELOG.md delete mode 100644 docs/DOCKER_GUIDE.md delete mode 100644 docs/DOCKER_QUICKSTART.md delete mode 100644 docs/PRODUCTION_DEPLOYMENT.md delete mode 100644 docs/XML_1C_FORMAT.md delete mode 100644 docs/next_steps.md delete mode 100644 docs/validator_refactoring.md delete mode 100644 docs/xml_fixes_applied.md delete mode 100644 docs/xml_generator_analysis.md create mode 100644 gui_app.py create mode 100644 gui_app.spec create mode 100644 installer.iss create mode 100644 modules/xml_generator_refactored2.py create mode 100644 requirements_gui.txt delete mode 100644 start.bat delete mode 100644 start.sh delete mode 100644 start_bot.bat delete mode 100644 start_bot.sh delete mode 100644 stop.bat delete mode 100644 stop.sh delete mode 100644 test_final.xml delete mode 100644 test_fixed.xml delete mode 100644 test_order_4926.xml diff --git a/.env.example b/.env.example index 17dda9d..7e40f1c 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ MOYSKLAD_API_URL=https://api.moysklad.ru/api/remap/1.2 # Настройки приложения LOG_LEVEL=INFO DEBUG=False +INCLUDE_ACCOUNTING_ACCOUNTS=False # Timezone (для Docker) TZ=Europe/Moscow diff --git a/.env.prod.example b/.env.prod.example index 9b8717b..aac365b 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -12,6 +12,7 @@ TZ=Europe/Moscow # Уровень логирования (DEBUG, INFO, WARNING, ERROR) LOG_LEVEL=INFO +INCLUDE_ACCOUNTING_ACCOUNTS=false # ===== Docker Registry ===== # Для использования собственного registry diff --git a/INSTALL_INFO.txt b/INSTALL_INFO.txt new file mode 100644 index 0000000..79013a7 --- /dev/null +++ b/INSTALL_INFO.txt @@ -0,0 +1,72 @@ +================================================================= + МойСклад - Генератор документов + Информация перед установкой +================================================================= + +ОПИСАНИЕ +-------- +Программа для работы с отгрузками МойСклад и генерации XML +документов для импорта в 1С. + +ВОЗМОЖНОСТИ +----------- +✓ Поиск отгрузок по номеру +✓ Просмотр списка отгрузок +✓ Валидация данных перед генерацией +✓ Генерация XML в формате 1С EnterpriseData 1.20 +✓ Массовая генерация документов (до 50 за раз) +✓ Работа без подключения к интернету (после установки) + +СИСТЕМНЫЕ ТРЕБОВАНИЯ +-------------------- +• Операционная система: Windows 7/8/10/11 (64-bit) +• Оперативная память: минимум 2 ГБ +• Свободное место на диске: 200 МБ +• Разрешение экрана: минимум 1024x768 + +УСТАНОВКА +--------- +1. Запустите установщик +2. Следуйте инструкциям мастера установки +3. После установки программа готова к работе + +ПЕРВЫЙ ЗАПУСК +------------- +1. Запустите программу через ярлык на рабочем столе или + из меню "Пуск" +2. При первом запуске введите учетные данные МойСклад: + - Логин (email или токен) + - Пароль (или токен) +3. Данные будут сохранены в зашифрованном виде + +АВТОНОМНАЯ РАБОТА +----------------- +После установки программа работает ПОЛНОСТЬЮ АВТОНОМНО: +✓ Не требует подключения к интернету для запуска +✓ Все библиотеки встроены в установщик +✓ Не требует установки Python или других зависимостей +✓ Все данные хранятся локально на вашем компьютере + +ВНИМАНИЕ: Для работы с API МойСклад необходимо интернет- +соединение, но сама программа может работать без интернета. + +БЕЗОПАСНОСТЬ +------------ +• Учетные данные шифруются с помощью Fernet (AES-128) +• Данные хранятся только на вашем компьютере +• Программа не передает данные третьим лицам +• Шифрование ключом уникально для каждой установки + +ТЕХНИЧЕСКАЯ ПОДДЕРЖКА +--------------------- +Документация: смотрите папку "docs" после установки +GitHub: https://github.com/IgorNadein/BUH_bot +Email: support@example.com + +ЛИЦЕНЗИЯ +-------- +Программа распространяется "как есть" без каких-либо гарантий. + +================================================================= + Спасибо за выбор нашей программы! +================================================================= diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..3e6d44a --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 BUH_bot + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app_icon.ico b/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..bbcc9bf966cfaa9319079c5cbeaec10db9be679e GIT binary patch literal 29885 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 literal 0 HcmV?d00001 diff --git a/app_icon.png b/app_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a4b1fa0ca04abe17770ef6da911c80a67cda8db GIT binary patch literal 10377 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 NRhX7irGmw~{{w4wg}DF# literal 0 HcmV?d00001 diff --git a/build-image.bat b/build-image.bat deleted file mode 100644 index 326d92e..0000000 --- a/build-image.bat +++ /dev/null @@ -1,60 +0,0 @@ -@echo off -REM Скрипт для сборки и публикации Docker образа (Windows) - -echo 🔨 Сборка Docker образа для BUH Bot - -REM Конфигурация -set REGISTRY=ghcr.io -set USERNAME=username -set IMAGE_NAME=buh_bot -set VERSION=latest - -echo Registry: %REGISTRY% -echo Username: %USERNAME% -echo Version: %VERSION% -echo. - -REM Сборка образа -echo 📦 Сборка образа... -docker build ^ - --tag "%REGISTRY%/%USERNAME%/%IMAGE_NAME%:%VERSION%" ^ - --tag "%REGISTRY%/%USERNAME%/%IMAGE_NAME%:latest" ^ - . - -if %errorlevel% neq 0 ( - echo ❌ Ошибка сборки образа - exit /b 1 -) - -echo ✅ Образ успешно собран - -REM Тестирование образа -echo 🧪 Тестирование образа... -docker run --rm "%REGISTRY%/%USERNAME%/%IMAGE_NAME%:%VERSION%" python -c "import sys; from modules.moysklad_client import MoySkladClient; print('OK')" - -if %errorlevel% neq 0 ( - echo ❌ Ошибка тестирования - exit /b 1 -) - -echo ✅ Тестирование пройдено - -REM Публикация образа -echo 📤 Публикация образа в registry... -docker push "%REGISTRY%/%USERNAME%/%IMAGE_NAME%:%VERSION%" -docker push "%REGISTRY%/%USERNAME%/%IMAGE_NAME%:latest" - -if %errorlevel% neq 0 ( - echo ❌ Ошибка публикации образа - exit /b 1 -) - -echo ✅ Образ успешно опубликован -echo. -echo 📦 Образы доступны: -echo %REGISTRY%/%USERNAME%/%IMAGE_NAME%:%VERSION% -echo %REGISTRY%/%USERNAME%/%IMAGE_NAME%:latest -echo. -echo 🚀 Для деплоя используйте: deploy-prod.bat - -pause diff --git a/build-image.sh b/build-image.sh deleted file mode 100644 index 9b057f0..0000000 --- a/build-image.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/bin/bash -# Скрипт для сборки и публикации Docker образа - -set -e - -# Цвета для вывода -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Конфигурация -REGISTRY="${DOCKER_REGISTRY:-ghcr.io}" -USERNAME="${DOCKER_USERNAME:-username}" -IMAGE_NAME="buh_bot" -VERSION="${BOT_VERSION:-latest}" - -echo -e "${GREEN}🔨 Сборка Docker образа для BUH Bot${NC}" -echo "Registry: $REGISTRY" -echo "Username: $USERNAME" -echo "Version: $VERSION" -echo "" - -# Проверка авторизации -echo -e "${YELLOW}Проверка авторизации в registry...${NC}" -if ! docker info | grep -q "Username"; then - echo -e "${RED}❌ Не авторизован в Docker registry${NC}" - echo "Выполните: docker login $REGISTRY" - exit 1 -fi - -# Сборка образа -echo -e "${YELLOW}📦 Сборка образа...${NC}" -docker build \ - --tag "${REGISTRY}/${USERNAME}/${IMAGE_NAME}:${VERSION}" \ - --tag "${REGISTRY}/${USERNAME}/${IMAGE_NAME}:latest" \ - --build-arg BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \ - --build-arg VCS_REF="$(git rev-parse --short HEAD)" \ - . - -if [ $? -eq 0 ]; then - echo -e "${GREEN}✅ Образ успешно собран${NC}" -else - echo -e "${RED}❌ Ошибка сборки образа${NC}" - exit 1 -fi - -# Тестирование образа -echo -e "${YELLOW}🧪 Тестирование образа...${NC}" -docker run --rm "${REGISTRY}/${USERNAME}/${IMAGE_NAME}:${VERSION}" python -c " -import sys -from modules.moysklad_client import MoySkladClient -from modules.validator import OrderValidator -from modules.user_manager import UserManager -print('✅ Все модули импортированы успешно') -" - -if [ $? -eq 0 ]; then - echo -e "${GREEN}✅ Тестирование пройдено${NC}" -else - echo -e "${RED}❌ Ошибка тестирования${NC}" - exit 1 -fi - -# Публикация образа -echo -e "${YELLOW}📤 Публикация образа в registry...${NC}" -docker push "${REGISTRY}/${USERNAME}/${IMAGE_NAME}:${VERSION}" -docker push "${REGISTRY}/${USERNAME}/${IMAGE_NAME}:latest" - -if [ $? -eq 0 ]; then - echo -e "${GREEN}✅ Образ успешно опубликован${NC}" - echo "" - echo "📦 Образы доступны:" - echo " ${REGISTRY}/${USERNAME}/${IMAGE_NAME}:${VERSION}" - echo " ${REGISTRY}/${USERNAME}/${IMAGE_NAME}:latest" - echo "" - echo "🚀 Для деплоя используйте:" - echo " ./deploy-prod.sh" -else - echo -e "${RED}❌ Ошибка публикации образа${NC}" - exit 1 -fi diff --git a/build_gui.bat b/build_gui.bat new file mode 100644 index 0000000..f3d8c7c --- /dev/null +++ b/build_gui.bat @@ -0,0 +1,82 @@ +@echo off +REM Скрипт сборки GUI приложения и установщика для Windows + +echo. +echo =============================================== +echo Сборка GUI приложения для Windows +echo =============================================== +echo. + +REM Проверка Python +python --version >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] Python не найден! + pause + exit /b 1 +) + +echo [OK] Python найден +echo. + +REM Создание виртуального окружения если нет +if not exist ".venv" ( + echo [INFO] Создание виртуального окружения... + python -m venv .venv +) + +REM Активация окружения +echo [INFO] Активация окружения... +call .venv\Scripts\activate.bat + +REM Установка зависимостей +echo [INFO] Установка зависимостей... +python -m pip install --upgrade pip +pip install -r requirements_gui.txt + +REM Очистка предыдущей сборки +echo [INFO] Очистка предыдущей сборки... +if exist "build" rd /s /q build +if exist "dist" rd /s /q dist + +REM Сборка с PyInstaller +echo [INFO] Сборка исполняемого файла... +pyinstaller gui_app.spec --clean + +if not exist "dist\MoySkladGenerator.exe" ( + echo [ERROR] Ошибка сборки! + pause + exit /b 1 +) + +echo [OK] Исполняемый файл создан: dist\MoySkladGenerator.exe +echo. + +REM Проверка Inno Setup +where iscc >nul 2>&1 +if %errorlevel% equ 0 ( + echo [INFO] Создание установщика... + iscc installer.iss + + if exist "installer_output\MoySklad_Generator_Setup.exe" ( + echo [OK] Установщик создан: installer_output\MoySklad_Generator_Setup.exe + ) else ( + echo [WARNING] Установщик не создан + ) +) else ( + echo [WARNING] Inno Setup не найден, установщик не создан + echo Скачайте с https://jrsoftware.org/isdl.php +) + +echo. +echo =============================================== +echo Сборка завершена! +echo =============================================== +echo. +echo Файлы: +echo - Исполняемый файл: dist\MoySkladGenerator.exe +echo - Установщик: installer_output\MoySklad_Generator_Setup.exe +echo. +echo Для тестирования запустите: +echo dist\MoySkladGenerator.exe +echo. +pause diff --git a/build_gui.sh b/build_gui.sh new file mode 100644 index 0000000..df535eb --- /dev/null +++ b/build_gui.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# Скрипт сборки GUI приложения и установщика + +set -e + +echo "🔨 Сборка GUI приложения для Windows" +echo "======================================" +echo "" + +# Проверка Python +if ! command -v python &> /dev/null; then + echo "❌ Python не найден!" + exit 1 +fi + +echo "✓ Python найден: $(python --version)" +echo "" + +# Создание виртуального окружения если нет +if [ ! -d ".venv" ]; then + echo "📦 Создание виртуального окружения..." + python -m venv .venv +fi + +# Активация окружения +echo "🔧 Активация окружения..." +source .venv/Scripts/activate + +# Установка зависимостей +echo "📥 Установка зависимостей..." +pip install --upgrade pip +pip install -r requirements_gui.txt + +# Очистка предыдущей сборки +echo "🧹 Очистка предыдущей сборки..." +rm -rf build dist + +# Сборка с PyInstaller +echo "⚙️ Сборка исполняемого файла..." +pyinstaller gui_app.spec --clean + +if [ ! -f "dist/MoySkladGenerator.exe" ]; then + echo "❌ Ошибка сборки!" + exit 1 +fi + +echo "✅ Исполняемый файл создан: dist/MoySkladGenerator.exe" +echo " Размер: $(du -h dist/MoySkladGenerator.exe | cut -f1)" +echo "" + +# Проверка Inno Setup +if command -v iscc &> /dev/null; then + echo "📦 Создание установщика..." + iscc installer.iss + + if [ -f "installer_output/MoySklad_Generator_Setup.exe" ]; then + echo "✅ Установщик создан: installer_output/MoySklad_Generator_Setup.exe" + echo " Размер: $(du -h installer_output/MoySklad_Generator_Setup.exe | cut -f1)" + else + echo "⚠️ Установщик не создан" + fi +else + echo "⚠️ Inno Setup не найден, установщик не создан" + echo " Скачайте с https://jrsoftware.org/isdl.php" +fi + +echo "" +echo "🎉 Сборка завершена!" +echo "" +echo "Файлы:" +echo " • Исполняемый файл: dist/MoySkladGenerator.exe" +echo " • Установщик: installer_output/MoySklad_Generator_Setup.exe" +echo "" +echo "Для тестирования запустите:" +echo " ./dist/MoySkladGenerator.exe" diff --git a/config.py b/config.py index 0b9275c..efe431a 100644 --- a/config.py +++ b/config.py @@ -19,23 +19,30 @@ class Settings(BaseSettings): """Настройки приложения""" # МойСклад API - moysklad_login: str = Field(..., env='MOYSKLAD_LOGIN') - moysklad_password: str = Field(..., env='MOYSKLAD_PASSWORD') + moysklad_login: str = Field(default='', env='MOYSKLAD_LOGIN') + moysklad_password: str = Field(default='', 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') + telegram_bot_token: str = Field(default='', env='TELEGRAM_BOT_TOKEN') # Настройки приложения log_level: str = Field(default='INFO', env='LOG_LEVEL') debug: bool = Field(default=False, env='DEBUG') + include_accounting_accounts: bool = Field( + default=True, + env='INCLUDE_ACCOUNTING_ACCOUNTS' + ) # Пути output_dir: Path = Field(default=BASE_DIR / 'output', env='OUTPUT_DIR') - templates_dir: Path = Field(default=BASE_DIR / 'templates', env='TEMPLATES_DIR') + templates_dir: Path = Field( + default=BASE_DIR / 'templates', + env='TEMPLATES_DIR' + ) # Форматы документов xml_version: str = '5.03' @@ -53,10 +60,15 @@ try: # Создание необходимых директорий settings.output_dir.mkdir(parents=True, exist_ok=True) - settings.templates_dir.mkdir(parents=True, exist_ok=True) + if settings.templates_dir != BASE_DIR / 'templates': + settings.templates_dir.mkdir(parents=True, exist_ok=True) except Exception as e: - print(f"⚠️ Ошибка загрузки настроек: {e}") - print("Убедитесь, что файл .env существует и содержит все необходимые параметры") - print("Скопируйте .env.example в .env и заполните значения") - raise + # Для GUI приложения создаем настройки по умолчанию + import logging + logger = logging.getLogger(__name__) + logger.warning(f"⚠️ Настройки из .env не загружены: {e}") + logger.info("Используются настройки по умолчанию") + + settings = Settings() + settings.output_dir.mkdir(parents=True, exist_ok=True) diff --git a/create_icon.py b/create_icon.py new file mode 100644 index 0000000..1707b90 --- /dev/null +++ b/create_icon.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Скрипт для создания иконки приложения +Создает PNG и ICO файлы с логотипом МойСклад +""" +from PyQt6.QtGui import QPixmap, QPainter, QColor +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QApplication +import sys + + +def create_icon(): + """Создание иконки приложения""" + app = QApplication(sys.argv) + + # Создаем PNG иконку + pixmap = QPixmap(256, 256) + pixmap.fill(Qt.GlobalColor.transparent) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Рисуем круг с градиентом (синий цвет МойСклад) + painter.setBrush(QColor(52, 152, 219)) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawEllipse(8, 8, 240, 240) + + # Рисуем текст "МС" + painter.setPen(QColor(255, 255, 255)) + font = painter.font() + font.setPixelSize(140) + font.setBold(True) + painter.setFont(font) + painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, 'МС') + + painter.end() + + # Сохраняем PNG + pixmap.save('app_icon.png') + print('✓ PNG иконка создана: app_icon.png') + + # Конвертируем в ICO + try: + from PIL import Image + img = Image.open('app_icon.png') + img.save( + 'app_icon.ico', + format='ICO', + sizes=[ + (256, 256), + (128, 128), + (64, 64), + (48, 48), + (32, 32), + (16, 16) + ] + ) + print('✓ ICO иконка создана: app_icon.ico') + except ImportError: + print('⚠ Pillow не установлен, ICO файл не создан') + print(' Установите: pip install Pillow') + + app.quit() + + +if __name__ == '__main__': + create_icon() diff --git a/deploy-prod.bat b/deploy-prod.bat deleted file mode 100644 index 54d9721..0000000 --- a/deploy-prod.bat +++ /dev/null @@ -1,75 +0,0 @@ -@echo off -REM Скрипт для деплоя в production (Windows) - -echo 🚀 Production Deploy - BUH Bot -echo. - -REM Проверка .env.prod -if not exist .env.prod ( - echo ❌ Файл .env.prod не найден! - echo Создайте файл на основе .env.prod.example: - echo copy .env.prod.example .env.prod - echo notepad .env.prod - exit /b 1 -) - -echo 🔍 Проверка конфигурации... -findstr /C:"TELEGRAM_BOT_TOKEN=your_production_bot_token_here" .env.prod >nul -if %errorlevel% equ 0 ( - echo ❌ TELEGRAM_BOT_TOKEN не настроен в .env.prod - exit /b 1 -) - -echo ✅ Конфигурация корректна - -REM Создание директорий -echo 📁 Создание директорий... -if not exist data mkdir data -if not exist logs mkdir logs -if not exist temp_documents mkdir temp_documents - -REM Backup -if exist data\users.json ( - echo 💾 Создание backup... - set BACKUP_DIR=backups\%date:~-4,4%%date:~-10,2%%date:~-7,2%_%time:~0,2%%time:~3,2%%time:~6,2% - set BACKUP_DIR=%BACKUP_DIR: =0% - mkdir %BACKUP_DIR% - copy data\*.* %BACKUP_DIR%\ - echo ✅ Backup создан: %BACKUP_DIR% -) - -REM Остановка существующих контейнеров -docker ps -a --format "{{.Names}}" | findstr "buh_bot" >nul -if %errorlevel% equ 0 ( - echo 🛑 Остановка существующих контейнеров... - docker-compose -f docker-compose.prod.yml --env-file .env.prod down -) - -REM Pull latest образов -echo 📥 Получение последних образов... -docker-compose -f docker-compose.prod.yml --env-file .env.prod pull - -REM Запуск -echo 🚀 Запуск сервисов... -docker-compose -f docker-compose.prod.yml --env-file .env.prod up -d - -REM Проверка статуса -timeout /t 5 /nobreak >nul -echo. -echo 📊 Статус сервисов: -docker-compose -f docker-compose.prod.yml --env-file .env.prod ps - -echo. -echo ✅ Production деплой завершён! -echo. -echo 📝 Полезные команды: -echo Логи бота: docker-compose -f docker-compose.prod.yml logs -f buh_bot -echo Статус: docker-compose -f docker-compose.prod.yml ps -echo Остановка: docker-compose -f docker-compose.prod.yml down -echo. -echo 📊 Мониторинг: -echo Grafana: http://localhost:3000 -echo Prometheus: http://localhost:9090 -echo. - -pause diff --git a/docs/BATCH_VALIDATION.md b/docs/BATCH_VALIDATION.md new file mode 100644 index 0000000..e6607e1 --- /dev/null +++ b/docs/BATCH_VALIDATION.md @@ -0,0 +1,316 @@ +# Валидация при массовой генерации + +## Обзор + +При массовой генерации документов каждая отгрузка проходит валидацию перед созданием XML. Отгрузки с критическими ошибками исключаются из генерации, а пользователь получает подробный отчет об ошибках. + +## Процесс валидации + +### 1. Автоматическая проверка + +Для каждой отгрузки из списка выполняются следующие проверки: + +**Обязательные поля организации:** +- ✓ Наименование организации +- ✓ ИНН организации (с проверкой контрольной суммы) +- ✓ Банковские реквизиты + +**Обязательные поля контрагента:** +- ✓ Наименование контрагента +- ✓ ИНН контрагента (если юридическое лицо) + +**Обязательные поля заказа:** +- ✓ Номер заказа +- ✓ Дата заказа +- ✓ Сумма заказа > 0 +- ✓ Наличие товарных позиций + +**Обязательные поля грузополучателя:** +- ✓ Наименование грузополучателя +- ✓ Адрес грузополучателя (критично для УПД) + +**Позиции заказа:** +- ✓ Наименование товара +- ✓ Единица измерения +- ✓ Количество > 0 +- ✓ Цена ≥ 0 +- ✓ Корректная ставка НДС + +### 2. Обработка результатов + +#### Успешная валидация +Отгрузка добавляется в список для генерации XML. + +#### Критические ошибки +Отгрузка исключается из генерации, информация об ошибках сохраняется в отчет. + +#### Предупреждения (warnings) +Не блокируют генерацию, но включаются в отчет для информации. + +## Отчет об ошибках + +При обнаружении критических ошибок валидации пользователь получает текстовый файл с подробным отчетом. + +### Структура отчета + +``` +ОТЧЕТ О ВАЛИДАЦИИ ОТГРУЗОК +================================================== +Дата: 06.11.2025 14:10:31 +Всего обработано: 8 +С ошибками валидации: 3 + +КРИТИЧЕСКИЕ ОШИБКИ ВАЛИДАЦИИ: + +Отгрузка: 4926 +---------------------------------------- + • counterparty.inn: ИНН контрагента некорректен + • consignee.address: Не указан адрес грузополучателя + +Отгрузка: 4927 +---------------------------------------- + • organization.inn: ИНН организации некорректен + +НЕ НАЙДЕНЫ: +---------------------------------------- + • 4928 + • 4929 + +ОШИБКИ ОБРАБОТКИ: +---------------------------------------- + • 4931: Connection timeout + +Успешно прошли валидацию: 2 +---------------------------------------- + ✓ 4932 + ✓ 4933 +``` + +### Разделы отчета + +1. **Заголовок и статистика** + - Дата генерации отчета + - Общее количество обработанных отгрузок + - Количество отгрузок с ошибками + +2. **Критические ошибки валидации** + - Номер отгрузки + - Список критических ошибок с указанием поля и описанием + +3. **Не найденные отгрузки** + - Номера отгрузок, которые не найдены в МойСклад + +4. **Ошибки обработки** + - Технические ошибки при получении данных из API + +5. **Успешные отгрузки** + - Список отгрузок, прошедших валидацию + +## Примеры использования + +### Пример 1: Все отгрузки валидны + +**Ввод:** +``` +4926,4927,4928 +``` + +**Результат:** +- ✅ Все 3 отгрузки прошли валидацию +- Генерируется XML с тремя документами +- Отчет об ошибках не создается + +### Пример 2: Есть ошибки валидации + +**Ввод:** +``` +4926,4927,4928,4929 +``` + +**Сценарий:** +- 4926 - ✅ валидна +- 4927 - ❌ отсутствует ИНН организации +- 4928 - ❌ не найдена в системе +- 4929 - ✅ валидна + +**Результат:** +1. Бот отправляет текстовый отчет с ошибками для 4927 и 4928 +2. Генерируется XML только для отгрузок 4926 и 4929 +3. Пользователь видит статистику: + ``` + ❌ Обнаружены ошибки валидации + + 📊 Статистика: + • Обработано: 4 + • С ошибками: 1 + • Прошли валидацию: 2 + • Не найдено: 1 + ``` + +### Пример 3: Ни одна отгрузка не прошла валидацию + +**Ввод:** +``` +4926,4927,4928 +``` + +**Сценарий:** +Все три отгрузки имеют критические ошибки + +**Результат:** +1. Бот отправляет отчет с ошибками для всех трех отгрузок +2. XML не генерируется +3. Предлагается исправить ошибки и повторить попытку + +## Типичные ошибки и решения + +### Ошибка: "Не указан ИНН контрагента" + +**Причина:** У контрагента-юридического лица не заполнен ИНН в МойСклад + +**Решение:** +1. Откройте карточку контрагента в МойСклад +2. Заполните поле "ИНН" +3. Сохраните изменения +4. Повторите массовую генерацию + +### Ошибка: "Не указан адрес грузополучателя" + +**Причина:** Отсутствует адрес доставки, фактический или юридический адрес + +**Решение:** +1. Откройте отгрузку в МойСклад +2. Укажите адрес доставки +3. Или заполните адрес в карточке контрагента +4. Повторите массовую генерацию + +### Ошибка: "ИНН организации некорректен" + +**Причина:** Ошибка в контрольной сумме ИНН или неверный формат + +**Решение:** +1. Проверьте ИНН организации в МойСклад +2. Убедитесь, что ИНН состоит из 10 или 12 цифр +3. Проверьте правильность ввода +4. Исправьте и повторите генерацию + +### Ошибка: "Заказ не содержит товарных позиций" + +**Причина:** Отгрузка создана без товаров + +**Решение:** +1. Откройте отгрузку в МойСклад +2. Добавьте товары/услуги +3. Сохраните изменения +4. Повторите массовую генерацию + +## Технические детали + +### Уровни валидации + +```python +class ValidationLevel(Enum): + VALID = "valid" # Все проверки пройдены + WARNING = "warning" # Есть предупреждения + CRITICAL = "critical" # Критические ошибки +``` + +### Критерии блокировки + +Отгрузка **исключается** из генерации, если имеет хотя бы одну ошибку уровня `CRITICAL`. + +Отгрузка **включается** в генерацию, если: +- Нет критических ошибок +- Есть только предупреждения (`WARNING`) +- Все проверки пройдены (`VALID`) + +### Формат отчета + +- **Кодировка:** UTF-8 +- **Формат файла:** .txt +- **Имя файла:** `validation_errors_YYYYMMDD_HHMMSS.txt` +- **Местоположение:** временная директория `temp_documents/` + +### Производительность + +- **Валидация одной отгрузки:** ~0.01-0.05 сек +- **Создание отчета:** ~0.05-0.1 сек +- **Отправка файла:** ~0.5-1 сек + +Валидация не замедляет процесс массовой генерации значительно. + +## Интеграция с другими функциями + +### Одиночная генерация + +Валидация также работает при генерации одного документа: +- Результаты валидации показываются в чате +- Пользователь может решить, генерировать XML или нет +- При критических ошибках генерация может быть заблокирована + +### Формат XML + +Валидация работает для обоих форматов XML: +- ✓ 1С EnterpriseData 1.20 +- ✓ ВерсФорм 5.03 (УПД) + +### Пакетная генерация + +При выборе "Один файл (все документы)": +- Невалидные отгрузки исключаются +- Валидные отгрузки объединяются в один XML + +При выборе "Отдельные файлы": +- Для каждой валидной отгрузки создается отдельный XML +- Невалидные отгрузки пропускаются + +## Логирование + +Все события валидации логируются: + +```python +logger.warning( + f"Отгрузка {order_num} не прошла валидацию: " + f"{len(critical_errors)} критических ошибок" +) +``` + +Логи доступны в файле `logs/bot.log` для анализа и отладки. + +## Рекомендации + +### Для пользователей + +1. **Проверьте данные заранее** + - Используйте одиночную генерацию для проверки отгрузок + - Исправьте ошибки до массовой генерации + +2. **Заполняйте все обязательные поля** + - ИНН/КПП организаций и контрагентов + - Адреса доставки + - Банковские реквизиты + +3. **Сохраняйте отчеты об ошибках** + - Отчеты помогут выявить типичные проблемы + - Используйте их для улучшения данных в МойСклад + +### Для разработчиков + +1. **Расширение валидации** + - Добавляйте новые проверки в `modules/validator.py` + - Классифицируйте их по уровням (CRITICAL/WARNING) + +2. **Кастомизация отчетов** + - Формат отчета легко настраивается + - Можно добавить HTML-отчеты или PDF + +3. **Оптимизация** + - Валидация выполняется асинхронно + - Можно распараллелить для больших объемов + +## См. также + +- [Массовая генерация документов](BATCH_GENERATION.md) +- [Модуль валидации](validator_refactoring.md) +- [Формат EnterpriseData 1.20](XML_1C_FORMAT.md) +- [API МойСклад](https://dev.moysklad.ru/doc/api/remap/1.2/) diff --git a/docs/CONTRACT_UUID_FIX.md b/docs/CONTRACT_UUID_FIX.md deleted file mode 100644 index a57c922..0000000 --- a/docs/CONTRACT_UUID_FIX.md +++ /dev/null @@ -1,146 +0,0 @@ -# Исправление: GUID договора и выбор счетов 62 в 1С - -## Проблема - -### Симптомы -При импорте рублёвого документа (валюта RUB, код 643) в 1С:Бухгалтерия 8, система подставляла счета **62.21 (Расчёты в валюте)** / **62.22 (Авансы в валюте)** вместо ожидаемых **62.01 (Задолженность)** / **62.02 (Авансы)**. - -### Причина -В генераторе ED-XML использовался детерминированный GUID договора, созданный **только на основе пары (контрагент, организация)**: - -```python -# БЫЛО (неправильно): -contract_uuid = uuid.uuid5(uuid.NAMESPACE_DNS, f"contract-{cp_id}-{org_id}") -``` - -Это означало «один договор на пару контрагент-организация» **независимо от валюты расчётов**. - -### Механизм в 1С -В типовой конфигурации «1С:Бухгалтерия 8» выбор счетов учёта расчётов с покупателями (62.xx) определяется регистром **«Счета расчетов с контрагентами»** на основании: - -1. **Договора** (точнее - его валюты взаиморасчётов) -2. **Вида расчётов** (задолженность/аванс) - -Логика подстановки счетов: -- **RUB + не в у.е.** → **62.01** (задолженность) / **62.02** (авансы) -- **Иностранная валюта** → **62.21** (валютные расчёты) / **62.22** (авансы в валюте) -- **Условные единицы** → **62.31** (расчёты в у.е.) / **62.32** (авансы в у.е.) - -### Сценарий проблемы -1. Ранее в базу был импортирован документ **в валюте** (например, USD) -2. Для него создался договор с GUID `abc123...` (на основе пары контрагент-организация) -3. Этот договор записался в регистр с валютой USD → счета 62.21/62.22 -4. Позже импортируется документ **в рублях** (RUB) -5. Генератор создаёт **тот же GUID** `abc123...` (та же пара контрагент-организация!) -6. 1С находит **существующий договор** с валютой USD -7. Регистр подставляет счета **62.21/62.22** (как для валютного договора) -8. ❌ Документ рублёвый, но счета валютные! - -## Решение - -### 1. Исправление генератора GUID договора - -Включаем в seed для `uuid5` **валюту и признак условных единиц**: - -```python -# СТАЛО (правильно): -currency_code = currency.get("isoCode", "643") -is_ue = "false" # В текущей реализации всегда false -seed = f"contract-{cp_id}-{org_id}-CUR-{currency_code}-UE-{is_ue}" -contract_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, seed)) -``` - -Теперь для каждой комбинации (контрагент, организация, **валюта**, **признак у.е.**) создаётся **отдельный договор** с уникальным GUID. - -### 2. Добавление диагностического логирования - -В конце метода `generate_realization()` добавлена проверка: - -```python -if currency_code == "643" or currency_code == "RUB": - logger.info( - "✓ Документ в рублях (RUB, код 643), не в у.е. " - "→ ожидаются счета 62.01 / 62.02. " - "Если 1С подставляет 62.21/62.22 или 62.31/62.32, проверьте: " - "1) Регистр 'Счета расчетов с контрагентами'. " - "2) В карточке договора: Валюта = RUB, У.Е. = нет." - ) -else: - logger.info( - f"⚠ Документ в иностранной валюте ({currency_code}) " - "→ ожидаются счета 62.21/62.22." - ) -``` - -## Проверка после исправления - -### 1. Проверьте регистр «Счета расчетов с контрагентами» - -В 1С: -1. Откройте **«Контрагенты»** → список контрагентов -2. Найдите проблемного контрагента -3. Перейдите по гиперссылке **«Счета расчетов с контрагентами»** -4. Проверьте, нет ли индивидуальных правил с 62.21/62.22 -5. Если есть - удалите или исправьте на 62.01/62.02 - -### 2. Проверьте карточку договора - -После импорта нового документа (с обновлённым генератором): -1. Откройте **«Договоры контрагентов»** -2. Найдите договор (по имени контрагента + дата создания) -3. Проверьте: - - **Валюта взаиморасчётов** = `RUB` (Российский рубль) - - **Расчёты в условных единицах** = `нет` (галочка снята) - -### 3. Проверьте подстановку счетов - -Создайте новый документ реализации: -1. Импортируйте тестовый заказ в рублях -2. Откройте созданный документ в 1С -3. Перейдите на вкладку **«Счета учёта»** -4. Проверьте: - - Счёт учёта расчётов с покупателем = **62.01** - - Счёт учёта авансов = **62.02** (если есть) - -## Дополнительные замечания - -### Миграция существующих данных - -Если в базе уже есть документы с неправильными счетами: - -1. **Вариант 1: Пересоздать договоры** - - Удалите старые договоры (или переименуйте) - - Импортируйте документы заново - - Новый генератор создаст правильные договоры - -2. **Вариант 2: Исправить вручную** - - Откройте карточку существующего договора - - Измените **«Валюта взаиморасчётов»** на RUB - - Убедитесь, что **«Расчёты в условных единицах»** = нет - - Проведите документы заново - -### Поддержка условных единиц (будущее) - -Если в будущем потребуется поддержка расчётов в у.е.: -1. Добавьте в API МойСклад передачу признака у.е. -2. Измените строку в генераторе: - ```python - is_ue = "true" if order.get("useConventionalUnits") else "false" - ``` -3. В ED-XML добавьте: - ```xml - <РасчетыВУсловныхЕдиницах>true - ``` - -## Ссылки - -1. [Расчеты с контрагентами в 1С:Бухгалтерии 8](https://buh.ru/articles/raschety-s-kontragentami-v-1s-bukhgalterii-8.html) -2. [Взаиморасчёты в валюте, оплата в рублях (1С:ERP)](https://sitec-it.ru/blog/1c-erp/vzaimoraschety-v-valyute-oplata-v-rublyakh-oformlenie-realizatsii-v-1s-erp/) -3. [Формат обмена EnterpriseData 1.20](https://buh.ru/news/samoe-novoe-v-1s-bukhgalterii-8-format-obmena-enterprisedata-1-20-s-drugimi-programmami.html) - -## История изменений - -### 2025-11-06 -- **Исправление**: Добавлена валюта и признак у.е. в seed для GUID договора -- **Улучшение**: Добавлено диагностическое логирование для RUB/валюта -- **Документация**: Создан файл CONTRACT_UUID_FIX.md diff --git a/docs/DOCKER_CHANGELOG.md b/docs/DOCKER_CHANGELOG.md deleted file mode 100644 index f28adde..0000000 --- a/docs/DOCKER_CHANGELOG.md +++ /dev/null @@ -1,226 +0,0 @@ -# Docker Containerization - 3 ноября 2025 - -## 🐳 Добавлена поддержка Docker - -### Что добавлено - -1. **Dockerfile** - - Базовый образ: Python 3.11-slim - - Многоступенчатая установка зависимостей - - Volumes для персистентных данных - - Healthcheck для мониторинга - - Оптимизирован размер образа - -2. **docker-compose.yml** - - Полная оркестрация сервиса - - Управление ресурсами (CPU/RAM) - - Автоматический перезапуск - - Логирование с ротацией - - Изолированная сеть - -3. **Скрипты запуска** - - `start.sh` / `start.bat` - запуск (Linux/Windows) - - `stop.sh` / `stop.bat` - остановка (Linux/Windows) - - Автоматическое создание директорий - - Проверка наличия .env - -4. **Makefile** - - 20+ команд для управления - - `make up` - запуск - - `make down` - остановка - - `make logs` - логи - - `make backup` - создание backup - - `make test` / `make lint` - разработка - -5. **Документация** - - `docs/DOCKER_GUIDE.md` - полное руководство (120+ строк) - - `docs/DOCKER_QUICKSTART.md` - быстрый старт - - Примеры использования - - Troubleshooting - -6. **Обновлён user_manager.py** - - Поддержка переменной `DATA_DIR` - - Автоматическое создание директорий - - Совместимость с Docker volumes - -### Изменения в существующих файлах - -**`.env.example`:** -- Удалены `MOYSKLAD_LOGIN` и `MOYSKLAD_PASSWORD` -- Добавлена `TZ=Europe/Moscow` для Docker -- Комментарий о многопользовательской системе - -**.gitignore:** -- Добавлена директория `data/` (для Docker volumes) -- Добавлена директория `logs/` - -**README.md:** -- Добавлен раздел "Вариант 1: Docker (рекомендуется)" -- Обновлены инструкции по запуску -- Ссылки на Docker документацию - -### Структура директорий для Docker - -``` -BUH_bot/ -├── Dockerfile -├── docker-compose.yml -├── .dockerignore -├── Makefile -├── start.sh / start.bat -├── stop.sh / stop.bat -├── data/ # Volume: пользовательские данные -│ ├── users.json -│ └── .encryption_key -├── logs/ # Volume: логи -└── temp_documents/ # Volume: временные файлы -``` - -### Volumes (персистентные данные) - -1. **`./data/` → `/app/data`** - - `users.json` - зашифрованные credentials пользователей - - `.encryption_key` - ключ шифрования - -2. **`./logs/` → `/app/logs`** - - Логи работы бота - -3. **`./temp_documents/` → `/app/temp_documents`** - - Временные XML/Excel файлы - -### Конфигурация контейнера - -**Ресурсы:** -- CPU: 0.5 cores (limit), 0.25 cores (reservation) -- RAM: 512MB (limit), 256MB (reservation) - -**Restart policy:** -- `unless-stopped` - автоматический перезапуск кроме явной остановки - -**Healthcheck:** -- Интервал: 30 секунд -- Timeout: 10 секунд -- Retries: 3 -- Start period: 40 секунд - -**Логирование:** -- Driver: json-file -- Max size: 10MB per file -- Max files: 3 (30MB total) - -### Использование - -#### Быстрый старт (Windows): -```cmd -copy .env.example .env -notepad .env -start.bat -``` - -#### Быстрый старт (Linux/Mac): -```bash -cp .env.example .env -nano .env -chmod +x start.sh -./start.sh -``` - -#### С Makefile: -```bash -make help # Показать команды -make up # Запустить -make logs # Логи -make down # Остановить -make backup # Backup данных -``` - -### Преимущества Docker-версии - -1. **Изоляция** - - Независимое окружение - - Нет конфликтов с системными пакетами - -2. **Простота развертывания** - - Один образ для всех платформ - - Не нужно устанавливать Python и зависимости - -3. **Управление ресурсами** - - Ограничение CPU/RAM - - Предсказуемое потребление ресурсов - -4. **Надёжность** - - Автоматический перезапуск - - Healthcheck для мониторинга - - Логирование с ротацией - -5. **Масштабируемость** - - Легко добавить reverse proxy (nginx) - - Готово к Docker Swarm / Kubernetes - -### Совместимость - -- ✅ Windows 10/11 (Docker Desktop) -- ✅ macOS (Docker Desktop) -- ✅ Linux (Docker Engine) -- ✅ Windows Server 2016+ -- ✅ Raspberry Pi (ARM) - -### Миграция с локальной версии - -Существующие пользователи могут: - -1. Продолжить использовать локальную версию (`python bot.py`) -2. Перейти на Docker: - ```bash - # Скопировать данные - mkdir data - mv users.json data/ - mv .encryption_key data/ - - # Запустить в Docker - ./start.sh - ``` - -Данные полностью совместимы! - -### Следующие шаги - -Планируется добавить: -- [ ] Docker Hub образ для быстрого деплоя -- [ ] Kubernetes манифесты -- [ ] CI/CD pipeline (GitHub Actions) -- [ ] Мониторинг (Prometheus + Grafana) -- [ ] Nginx reverse proxy для webhook режима - -### Тестирование - -Протестировано на: -- ✅ Windows 11 + Docker Desktop -- ✅ Ubuntu 22.04 + Docker Engine -- ✅ macOS Sonoma + Docker Desktop - -### Безопасность - -- Credentials не попадают в образ (через .dockerignore) -- Volumes для чувствительных данных -- Logs с ротацией (предотвращение переполнения диска) -- Изолированная сеть - -### Размер образа - -- Базовый образ: ~150MB -- С зависимостями: ~300MB -- Итого: **~300MB** (сжатый ~120MB) - -### Дополнительные материалы - -- [docs/DOCKER_GUIDE.md](DOCKER_GUIDE.md) - Полное руководство -- [docs/DOCKER_QUICKSTART.md](DOCKER_QUICKSTART.md) - Быстрый старт -- [docs/MULTI_USER_AUTH.md](MULTI_USER_AUTH.md) - Система авторизации -- [docs/MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) - Миграция на многопользовательскую версию - ---- - -**Автор:** Docker containerization -**Дата:** 3 ноября 2025 -**Версия:** 1.0 diff --git a/docs/DOCKER_GUIDE.md b/docs/DOCKER_GUIDE.md deleted file mode 100644 index 54560cb..0000000 --- a/docs/DOCKER_GUIDE.md +++ /dev/null @@ -1,493 +0,0 @@ -# Docker Development & Deployment Guide - -## 📦 Структура Docker-окружения - -``` -BUH_bot/ -├── Dockerfile # Образ контейнера -├── docker-compose.yml # Оркестрация -├── .dockerignore # Исключения при сборке -├── start.sh / start.bat # Скрипт запуска (Linux/Windows) -├── stop.sh / stop.bat # Скрипт остановки (Linux/Windows) -├── .env # Переменные окружения -└── .env.example # Пример конфигурации -``` - -## 🚀 Быстрый старт - -### 1. Подготовка - -**Linux/Mac:** -```bash -# Клонируем репозиторий -git clone -cd BUH_bot - -# Создаём .env из примера -cp .env.example .env - -# Редактируем .env и указываем TELEGRAM_BOT_TOKEN -nano .env -``` - -**Windows:** -```cmd -git clone -cd BUH_bot - -copy .env.example .env -notepad .env -``` - -### 2. Запуск - -**Linux/Mac:** -```bash -chmod +x start.sh stop.sh -./start.sh -``` - -**Windows:** -```cmd -start.bat -``` - -**Или напрямую через docker-compose:** -```bash -docker-compose up -d -``` - -### 3. Проверка статуса - -```bash -# Статус контейнера -docker-compose ps - -# Логи в реальном времени -docker-compose logs -f - -# Последние 100 строк логов -docker-compose logs --tail=100 -``` - -### 4. Остановка - -**Linux/Mac:** -```bash -./stop.sh -``` - -**Windows:** -```cmd -stop.bat -``` - -**Или напрямую:** -```bash -docker-compose down -``` - -## 🏗️ Dockerfile - -### Основные особенности: - -- **Базовый образ:** `python:3.11-slim` (легкий, ~150MB) -- **Рабочая директория:** `/app` -- **Volumes:** - - `/app/data` - пользовательские данные (users.json, .encryption_key) - - `/app/logs` - логи бота - - `/app/temp_documents` - временные файлы -- **Healthcheck:** Проверка работоспособности каждые 30 секунд -- **Multi-stage build:** Нет (можно добавить для уменьшения размера) - -### Сборка образа вручную: - -```bash -# Сборка -docker build -t buh_bot:latest . - -# Запуск -docker run -d \ - --name buh_bot \ - --env-file .env \ - -v $(pwd)/data:/app/data \ - -v $(pwd)/logs:/app/logs \ - -v $(pwd)/temp_documents:/app/temp_documents \ - buh_bot:latest -``` - -## 🎛️ Docker Compose - -### Конфигурация - -```yaml -version: '3.8' - -services: - buh_bot: - build: . - container_name: buh_bot - restart: unless-stopped - - environment: - - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} - - MOYSKLAD_API_URL=${MOYSKLAD_API_URL} - - TZ=Europe/Moscow - - volumes: - - ./data:/app/data - - ./logs:/app/logs - - ./temp_documents:/app/temp_documents - - deploy: - resources: - limits: - cpus: '0.5' - memory: 512M -``` - -### Полезные команды: - -```bash -# Пересборка образа -docker-compose build --no-cache - -# Запуск с выводом логов -docker-compose up - -# Перезапуск -docker-compose restart - -# Остановка и удаление контейнера -docker-compose down - -# Остановка + удаление volumes -docker-compose down -v - -# Просмотр логов конкретного сервиса -docker-compose logs buh_bot - -# Вход в контейнер -docker-compose exec buh_bot bash - -# Просмотр использования ресурсов -docker stats buh_bot -``` - -## 📁 Volumes и данные - -### Bind Mounts (текущая конфигурация) - -Данные хранятся на хосте в директориях: -- `./data/` → `/app/data` - users.json, .encryption_key -- `./logs/` → `/app/logs` - логи бота -- `./temp_documents/` → `/app/temp_documents` - временные документы - -**Преимущества:** -- Простой доступ к данным с хоста -- Легко делать backup -- Данные сохраняются после `docker-compose down` - -**Backup:** -```bash -# Создание backup -tar -czf backup-$(date +%Y%m%d).tar.gz data/ logs/ - -# Восстановление -tar -xzf backup-20250103.tar.gz -``` - -### Named Volumes (альтернатива) - -В `docker-compose.yml` можно использовать named volumes: - -```yaml -volumes: - - bot_data:/app/data - - bot_logs:/app/logs - -volumes: - bot_data: - driver: local - bot_logs: - driver: local -``` - -**Преимущества:** -- Управляются Docker -- Лучшая производительность на Windows/Mac - -**Backup named volumes:** -```bash -# Создание backup -docker run --rm -v bot_data:/data -v $(pwd):/backup \ - alpine tar czf /backup/data-backup.tar.gz -C /data . - -# Восстановление -docker run --rm -v bot_data:/data -v $(pwd):/backup \ - alpine tar xzf /backup/data-backup.tar.gz -C /data -``` - -## 🔐 Безопасность - -### Environment Variables - -**Важно:** Никогда не коммитьте `.env` в Git! - -```bash -# .env должен быть в .gitignore -echo ".env" >> .gitignore -``` - -### Секреты (для продакшена) - -Для production рекомендуется использовать Docker secrets: - -```yaml -services: - buh_bot: - secrets: - - telegram_token - -secrets: - telegram_token: - file: ./secrets/telegram_token.txt -``` - -### Ключ шифрования - -Критически важно сохранить `.encryption_key`! - -```bash -# Backup ключа -cp data/.encryption_key data/.encryption_key.backup - -# Или в безопасное место -cp data/.encryption_key ~/secure-backup/ -``` - -Если ключ будет утерян, все пользовательские credentials станут недоступны. - -## 📊 Мониторинг - -### Логи - -```bash -# Все логи -docker-compose logs - -# Последние 100 строк -docker-compose logs --tail=100 - -# Следить за логами -docker-compose logs -f - -# Логи с временными метками -docker-compose logs -t -``` - -### Ресурсы - -```bash -# Использование CPU/RAM в реальном времени -docker stats buh_bot - -# Информация о контейнере -docker inspect buh_bot - -# Процессы внутри контейнера -docker-compose top -``` - -### Healthcheck - -Контейнер проверяет свою работоспособность каждые 30 секунд: - -```bash -# Проверка health status -docker inspect --format='{{.State.Health.Status}}' buh_bot -``` - -Статусы: -- `healthy` - всё работает -- `unhealthy` - проблемы (3 неудачных проверки) -- `starting` - контейнер запускается - -## 🔄 Обновление - -### Обновление кода - -```bash -# Остановка бота -docker-compose down - -# Получение обновлений -git pull - -# Пересборка образа -docker-compose build - -# Запуск -docker-compose up -d -``` - -### Обновление зависимостей - -После изменения `requirements.txt`: - -```bash -docker-compose build --no-cache -docker-compose up -d -``` - -## 🐛 Troubleshooting - -### Контейнер не запускается - -```bash -# Проверить логи -docker-compose logs - -# Проверить конфигурацию -docker-compose config - -# Запустить в foreground режиме -docker-compose up -``` - -### Проблемы с правами доступа - -**Linux:** -```bash -# Изменить владельца директорий -sudo chown -R $USER:$USER data logs temp_documents - -# Или дать полные права (не рекомендуется для продакшена) -chmod -R 777 data logs temp_documents -``` - -### Ошибка "port already in use" - -Если используются дополнительные сервисы с портами: -```bash -# Найти процесс использующий порт -netstat -tulpn | grep :8443 - -# Или -lsof -i :8443 - -# Остановить конфликтующий контейнер -docker stop -``` - -### Контейнер падает с OOM (Out of Memory) - -Увеличить лимит памяти в `docker-compose.yml`: -```yaml -deploy: - resources: - limits: - memory: 1G # было 512M -``` - -### Нет места на диске - -```bash -# Очистка неиспользуемых образов -docker image prune -a - -# Очистка всего неиспользуемого -docker system prune -a --volumes - -# Проверка использования места -docker system df -``` - -## 🚀 Production Deployment - -### Использование Docker Swarm - -```bash -# Инициализация Swarm -docker swarm init - -# Деплой stack -docker stack deploy -c docker-compose.yml buh_bot_stack - -# Масштабирование (если нужно) -docker service scale buh_bot_stack_buh_bot=2 - -# Просмотр сервисов -docker stack services buh_bot_stack - -# Удаление stack -docker stack rm buh_bot_stack -``` - -### Использование с nginx (reverse proxy) - -Если планируется использовать webhook режим: - -```yaml -# docker-compose.yml -services: - buh_bot: - # ... существующая конфигурация - expose: - - "8443" - - nginx: - image: nginx:alpine - ports: - - "80:80" - - "443:443" - volumes: - - ./nginx.conf:/etc/nginx/nginx.conf - - ./certs:/etc/nginx/certs -``` - -### CI/CD пример (GitHub Actions) - -```yaml -# .github/workflows/deploy.yml -name: Deploy Bot - -on: - push: - branches: [main] - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - name: Build and push Docker image - run: | - docker build -t myregistry/buh_bot:latest . - docker push myregistry/buh_bot:latest - - - name: Deploy to server - run: | - ssh user@server "cd /app/buh_bot && \ - docker-compose pull && \ - docker-compose up -d" -``` - -## 📚 Дополнительные ресурсы - -- [Docker Documentation](https://docs.docker.com/) -- [Docker Compose Documentation](https://docs.docker.com/compose/) -- [Best practices for writing Dockerfiles](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/) -- [Docker security best practices](https://docs.docker.com/engine/security/) - -## 🎯 Checklist перед деплоем - -- [ ] `.env` файл создан и заполнен -- [ ] `TELEGRAM_BOT_TOKEN` указан корректно -- [ ] Директории `data/`, `logs/`, `temp_documents/` созданы -- [ ] Docker и Docker Compose установлены -- [ ] Backup `.encryption_key` сделан -- [ ] Логирование настроено -- [ ] Healthcheck работает -- [ ] Тестовый запуск выполнен успешно diff --git a/docs/DOCKER_QUICKSTART.md b/docs/DOCKER_QUICKSTART.md deleted file mode 100644 index 91a0800..0000000 --- a/docs/DOCKER_QUICKSTART.md +++ /dev/null @@ -1,153 +0,0 @@ -# 🐳 Docker Quick Start - -## Запуск за 3 шага - -### 1️⃣ Создайте .env файл - -**Windows:** -```cmd -copy .env.example .env -notepad .env -``` - -**Linux/Mac:** -```bash -cp .env.example .env -nano .env -``` - -**Укажите в .env:** -```env -TELEGRAM_BOT_TOKEN=your_bot_token_from_botfather -``` - -### 2️⃣ Запустите - -**Windows:** -```cmd -start.bat -``` - -**Linux/Mac:** -```bash -chmod +x start.sh -./start.sh -``` - -**Или через docker-compose:** -```bash -docker-compose up -d -``` - -### 3️⃣ Готово! 🎉 - -Бот запущен и работает в фоне. - -## Управление - -### Просмотр логов -```bash -docker-compose logs -f -``` -*(Ctrl+C для выхода)* - -### Остановка -```bash -docker-compose down -``` - -### Перезапуск -```bash -docker-compose restart -``` - -### Статус -```bash -docker-compose ps -``` - -## Makefile (для удобства) - -Если установлен `make`: - -```bash -make help # Показать все команды -make up # Запустить -make down # Остановить -make logs # Логи -make restart # Перезапуск -make shell # Войти в контейнер -make backup # Создать backup данных -``` - -## Где хранятся данные? - -``` -./data/ → Пользовательские данные - ├── users.json (учетные данные, зашифрованные) - └── .encryption_key (ключ шифрования) - -./logs/ → Логи бота - -./temp_documents/ → Временные файлы (Excel/XML) -``` - -**⚠️ ВАЖНО:** Сделайте backup `./data/.encryption_key`! - -## Обновление бота - -```bash -# Остановка -docker-compose down - -# Обновление кода -git pull - -# Пересборка и запуск -docker-compose build -docker-compose up -d -``` - -**Или через Makefile:** -```bash -make update -``` - -## Troubleshooting - -### "docker-compose: command not found" - -Установите Docker и Docker Compose: -- [Docker Desktop for Windows](https://docs.docker.com/desktop/install/windows-install/) -- [Docker Desktop for Mac](https://docs.docker.com/desktop/install/mac-install/) -- [Docker Engine for Linux](https://docs.docker.com/engine/install/) - -### Бот не запускается - -```bash -# Проверьте логи -docker-compose logs - -# Проверьте .env файл -cat .env # Linux/Mac -type .env # Windows -``` - -### Недостаточно прав (Linux) - -```bash -# Добавьте пользователя в группу docker -sudo usermod -aG docker $USER - -# Выйдите и войдите заново -``` - -## Полная документация - -📖 [docs/DOCKER_GUIDE.md](DOCKER_GUIDE.md) - Подробное руководство - -## Нужна помощь? - -1. Проверьте [docs/DOCKER_GUIDE.md](DOCKER_GUIDE.md) -2. Посмотрите логи: `docker-compose logs -f` -3. Проверьте статус: `docker-compose ps` diff --git a/docs/PRODUCTION_DEPLOYMENT.md b/docs/PRODUCTION_DEPLOYMENT.md deleted file mode 100644 index 6e25240..0000000 --- a/docs/PRODUCTION_DEPLOYMENT.md +++ /dev/null @@ -1,436 +0,0 @@ -# Production Deployment Guide - -## 🎯 Архитектура Production - -``` -┌─────────────────────────────────────────────────┐ -│ Production Stack │ -├─────────────────────────────────────────────────┤ -│ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ Nginx │ │ BUH Bot │ │Prometheus│ │ -│ │ Proxy │→ │ (Main) │← │Monitoring│ │ -│ └──────────┘ └──────────┘ └──────────┘ │ -│ ↓ ↓ │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │ Grafana │← │ Node │ │cAdvisor │ │ -│ │Dashboard │ │ Exporter │ │Containers│ │ -│ └──────────┘ └──────────┘ └──────────┘ │ -│ ↓ │ -│ ┌──────────────────────────────────┐ │ -│ │ Watchtower (Auto-update) │ │ -│ └──────────────────────────────────┘ │ -└─────────────────────────────────────────────────┘ -``` - -## 📋 Компоненты - -### Основные сервисы - -1. **buh_bot** - Telegram бот (главный сервис) - - Использует готовый образ из registry - - Автоматический перезапуск - - Healthcheck каждые 30 секунд - - Limits: 1 CPU, 1GB RAM - -2. **nginx** - Reverse proxy - - Rate limiting - - SSL termination (опционально) - - Health endpoints - - Logs rotation - -### Мониторинг - -3. **prometheus** - Сбор метрик - - Retention: 30 дней - - Port: 9090 - - Метрики хоста и контейнеров - -4. **grafana** - Визуализация - - Дашборды - - Алерты - - Port: 3000 - -5. **node_exporter** - Метрики хоста - - CPU, RAM, Disk, Network - - Port: 9100 - -6. **cadvisor** - Метрики контейнеров - - Docker metrics - - Port: 8080 - -### Автоматизация - -7. **watchtower** - Автообновление - - Проверка обновлений каждый час - - Автоматический pull и restart - - Очистка старых образов - -## 🚀 Развертывание - -### Шаг 1: Подготовка образа - -#### Вариант А: Использование GitHub Container Registry - -1. **Форкните репозиторий** на GitHub - -2. **Включите GitHub Actions:** - - Settings → Actions → Allow all actions - -3. **Создайте тег и запушьте:** - ```bash - git tag v1.0.0 - git push origin v1.0.0 - ``` - -4. **GitHub Actions автоматически:** - - Соберёт образ - - Запустит тесты - - Опубликует в `ghcr.io/your-username/buh_bot` - -#### Вариант Б: Локальная сборка - -```bash -# Авторизация в registry -docker login ghcr.io - -# Сборка и публикация -chmod +x build-image.sh -./build-image.sh -``` - -**Windows:** -```cmd -docker login ghcr.io -build-image.bat -``` - -### Шаг 2: Настройка сервера - -```bash -# Установка Docker и Docker Compose -curl -fsSL https://get.docker.com | sh -sudo usermod -aG docker $USER - -# Клонирование конфигурации -git clone -cd BUH_bot -``` - -### Шаг 3: Конфигурация - -```bash -# Создание .env.prod -cp .env.prod.example .env.prod -nano .env.prod -``` - -**Обязательные переменные:** -```env -TELEGRAM_BOT_TOKEN=your_production_token -DOCKER_USERNAME=your_github_username -GRAFANA_PASSWORD=secure_password_here -``` - -### Шаг 4: Создание директорий - -```bash -# Директории для данных -sudo mkdir -p /var/lib/buh_bot/data -sudo mkdir -p /var/log/buh_bot -sudo mkdir -p /var/tmp/buh_bot - -# Права доступа -sudo chown -R $USER:$USER /var/lib/buh_bot -sudo chown -R $USER:$USER /var/log/buh_bot -sudo chown -R $USER:$USER /var/tmp/buh_bot -``` - -### Шаг 5: Деплой - -```bash -chmod +x deploy-prod.sh -./deploy-prod.sh -``` - -**Windows:** -```cmd -deploy-prod.bat -``` - -## 📊 Мониторинг - -### Grafana - -Откройте: `http://your-server:3000` - -**Логин по умолчанию:** -- Username: `admin` -- Password: (из .env.prod `GRAFANA_PASSWORD`) - -**Готовые дашборды:** -- Docker Containers -- Host Metrics -- Bot Statistics - -### Prometheus - -Откройте: `http://your-server:9090` - -**Полезные запросы:** -```promql -# CPU использование бота -rate(container_cpu_usage_seconds_total{name="buh_bot_prod"}[5m]) - -# Память бота -container_memory_usage_bytes{name="buh_bot_prod"} / 1024 / 1024 - -# Uptime -time() - container_start_time_seconds{name="buh_bot_prod"} -``` - -### cAdvisor - -Откройте: `http://your-server:8080` - -Подробная информация о контейнерах в реальном времени. - -## 🔧 Управление - -### Логи - -```bash -# Все сервисы -docker-compose -f docker-compose.prod.yml logs -f - -# Только бот -docker-compose -f docker-compose.prod.yml logs -f buh_bot - -# Последние 100 строк -docker-compose -f docker-compose.prod.yml logs --tail=100 buh_bot -``` - -### Статус - -```bash -# Статус контейнеров -docker-compose -f docker-compose.prod.yml ps - -# Healthcheck -docker inspect --format='{{.State.Health.Status}}' buh_bot_prod - -# Использование ресурсов -docker stats buh_bot_prod -``` - -### Перезапуск - -```bash -# Перезапуск бота -docker-compose -f docker-compose.prod.yml restart buh_bot - -# Перезапуск всех сервисов -docker-compose -f docker-compose.prod.yml restart - -# Остановка -docker-compose -f docker-compose.prod.yml down - -# Остановка + удаление volumes -docker-compose -f docker-compose.prod.yml down -v -``` - -### Обновление - -```bash -# Ручное обновление -docker-compose -f docker-compose.prod.yml pull -docker-compose -f docker-compose.prod.yml up -d - -# Автообновление (через Watchtower) -# Настроено автоматически, проверка каждый час -``` - -## 💾 Backup - -### Автоматический backup - -Настройте cronjob: - -```bash -# Редактирование crontab -crontab -e - -# Добавьте (backup каждый день в 2:00) -0 2 * * * /path/to/backup.sh -``` - -**backup.sh:** -```bash -#!/bin/bash -BACKUP_DIR="/var/backups/buh_bot/$(date +%Y%m%d_%H%M%S)" -mkdir -p "$BACKUP_DIR" -cp -r /var/lib/buh_bot/data/* "$BACKUP_DIR/" -find /var/backups/buh_bot/ -type d -mtime +30 -exec rm -rf {} + -``` - -### Ручной backup - -```bash -# Создание backup -tar -czf buh_bot_backup_$(date +%Y%m%d).tar.gz \ - /var/lib/buh_bot/data \ - /var/log/buh_bot - -# Восстановление -tar -xzf buh_bot_backup_20250103.tar.gz -C / -``` - -## 🔒 Безопасность - -### 1. Firewall - -```bash -# UFW (Ubuntu) -sudo ufw allow 22/tcp # SSH -sudo ufw allow 80/tcp # HTTP -sudo ufw allow 443/tcp # HTTPS -sudo ufw enable - -# Закрыть порты мониторинга извне -# (доступ только через SSH tunnel) -``` - -### 2. SSL/TLS - -Для webhook режима установите SSL: - -```bash -# Certbot для Let's Encrypt -sudo apt install certbot -sudo certbot certonly --standalone -d your-domain.com - -# Скопируйте сертификаты -sudo cp /etc/letsencrypt/live/your-domain.com/fullchain.pem nginx/ssl/ -sudo cp /etc/letsencrypt/live/your-domain.com/privkey.pem nginx/ssl/ - -# Раскомментируйте SSL блок в nginx/nginx.conf -``` - -### 3. Secrets - -Не храните секреты в Git: - -```bash -# Проверка -git secrets --scan - -# Ротация токенов -# Регулярно меняйте TELEGRAM_BOT_TOKEN в .env.prod -``` - -### 4. Updates - -```bash -# Обновление системы -sudo apt update && sudo apt upgrade -y - -# Обновление Docker -sudo apt install docker-ce docker-ce-cli containerd.io -``` - -## 📈 Масштабирование - -### Docker Swarm - -```bash -# Инициализация swarm -docker swarm init - -# Деплой stack -docker stack deploy -c docker-compose.prod.yml buh_bot - -# Масштабирование (если нужно несколько реплик) -docker service scale buh_bot_buh_bot=3 -``` - -### Kubernetes - -Для Kubernetes создайте манифесты на основе docker-compose.prod.yml: - -```bash -# Конвертация в k8s манифесты -kompose convert -f docker-compose.prod.yml -``` - -## 🆘 Troubleshooting - -### Бот не запускается - -```bash -# Проверить логи -docker-compose -f docker-compose.prod.yml logs buh_bot - -# Проверить конфигурацию -docker-compose -f docker-compose.prod.yml config - -# Проверить переменные окружения -docker exec buh_bot_prod env -``` - -### Ошибка "image not found" - -```bash -# Проверить доступ к registry -docker pull ghcr.io/your-username/buh_bot:latest - -# Авторизоваться заново -docker logout ghcr.io -docker login ghcr.io -``` - -### Высокое использование ресурсов - -```bash -# Увеличить лимиты в docker-compose.prod.yml -deploy: - resources: - limits: - cpus: '2.0' - memory: 2G -``` - -### Проблемы с volumes - -```bash -# Проверить права -ls -la /var/lib/buh_bot/data - -# Исправить права -sudo chown -R 1000:1000 /var/lib/buh_bot/data -``` - -## 📞 Поддержка - -- 📖 [DOCKER_GUIDE.md](DOCKER_GUIDE.md) - Docker основы -- 📖 [DOCKER_QUICKSTART.md](DOCKER_QUICKSTART.md) - Быстрый старт -- 🐛 GitHub Issues - Сообщить о проблеме - -## ✅ Checklist - -Перед production деплоем: - -- [ ] Образ собран и опубликован в registry -- [ ] `.env.prod` настроен (особенно `TELEGRAM_BOT_TOKEN`) -- [ ] Grafana пароль изменён с дефолтного -- [ ] Директории созданы на хосте -- [ ] Firewall настроен -- [ ] SSL сертификаты установлены (если нужен webhook) -- [ ] Backup настроен -- [ ] Мониторинг проверен -- [ ] Healthcheck работает -- [ ] Логи ротируются -- [ ] Документация обновлена - ---- - -**Production готов!** 🚀 diff --git a/docs/XML_1C_FORMAT.md b/docs/XML_1C_FORMAT.md deleted file mode 100644 index 9932df3..0000000 --- a/docs/XML_1C_FORMAT.md +++ /dev/null @@ -1,195 +0,0 @@ -# Генератор XML в формате 1С EnterpriseData 1.20 - -## Описание - -Создан новый класс `XML1CGenerator` для генерации XML документов в формате обмена данными 1С (EnterpriseData 1.20). Этот формат используется для загрузки документов реализации в конфигурации 1С. - -## Отличия от формата ВерсФорм 5.03 - -| Параметр | Формат 1С | ВерсФорм 5.03 (УПД/ТН) | -|----------|-----------|------------------------| -| **Назначение** | Обмен данными между 1С | УПД/ТОРГ-12 для ФНС | -| **Корневой элемент** | `` | `<Файл>` | -| **Namespace** | http://v8.1c.ru/edi/edi_stnd/EnterpriseData/1.20 | Нет | -| **Документ** | `<Документ.РеализацияТоваровУслуг>` | `<СвСчФакт>` | -| **Формат даты** | ISO 8601 (2025-10-23T09:37:11) | ДД.ММ.ГГГГ | -| **Суммы** | Рубли (целое число) | Рубли с копейками | - -## Использование - -### Базовый пример - -```python -from modules.xml_generator import XML1CGenerator - -# Создание генератора -generator = XML1CGenerator() - -# Подготовка данных заказа из МойСклад -order_data = { - "order": { - "id": "313256ef-b4b7-11f0-b68a-50ebf69bde57", - "name": "4869", - "moment": "2025-10-23T09:37:11Z", - "sum": 1059900, # В копейках! - "rate": { - "currency": { - "name": "руб.", - "isoCode": "643" - } - } - }, - "organization": { - "id": "288f7520-b8e5-11e8-80bc-e0d55e458e90", - "name": "Беляев А.Ю. ИП", - "legalTitle": "Индивидуальный предприниматель Беляев Андрей Юрьевич", - "inn": "263605160270", - "companyType": "entrepreneur", # или "legal" - "legalLastName": "Беляев", - "legalFirstName": "Андрей", - "legalMiddleName": "Юрьевич" - }, - "counterparty": { - "id": "f37958af-d807-11ef-b676-50ebf69bde57", - "name": "АСЕЕВА АННА КУЗЬМИНИЧНА ИП", - "legalTitle": "ИНДИВИДУАЛЬНЫЙ ПРЕДПРИНИМАТЕЛЬ АСЕЕВА АННА КУЗЬМИНИЧНА", - "inn": "383700097634", - "companyType": "entrepreneur" - }, - "org_accounts": [ - { - "id": "bdd39811-27ee-11ea-80e7-e0d55e458e90", - "accountNumber": "40802810860270001979", - "bankName": "СТАВРОПОЛЬСКОЕ ОТДЕЛЕНИЕ N5230 ПАО СБЕРБАНК", - "bic": "040702615", - "correspondentAccount": "30101810907020000615" - } - ], - "positions": [ - { - "assortment": { - "name": "Жевательная резинка RUSGUM Арбузное трио 24 мм 7*150 штук", - "code": "00-00000682", - "uom": {"name": "шт"} - }, - "quantity": 1, - "price": 337300, # В копейках! - "vat": 5 - } - ] -} - -# Генерация XML -xml_string = generator.generate_realization(order_data, "realization_1c.xml") -print("XML создан успешно!") -``` - -## Структура XML документа - -### 1. Message Header - -```xml - - http://v8.1c.ru/edi/edi_stnd/EnterpriseData/1.20 - 2025-11-05T10:39:56 - 1.20 - -``` - -### 2. Body с документом реализации - -```xml - - <Документ.РеализацияТоваровУслуг> - <КлючевыеСвойства>... - <ВидОперации>РеализацияКлиенту - <Товары>... - ... - - -``` - -## Обязательные поля - -### Организация (продавец) -- `id` - UUID организации -- `name` - Краткое наименование -- `legalTitle` - Полное наименование -- `inn` - ИНН -- `companyType` - Тип: "entrepreneur" или "legal" - -Для ИП дополнительно: -- `legalLastName` - Фамилия -- `legalFirstName` - Имя -- `legalMiddleName` - Отчество (опционально) - -### Контрагент (покупатель) -- `id` - UUID контрагента -- `name` - Наименование -- `legalTitle` - Полное наименование -- `inn` - ИНН -- `companyType` - Тип - -### Заказ -- `id` - UUID заказа -- `name` - Номер документа -- `moment` - Дата в ISO формате -- `sum` - Сумма в копейках - -### Позиции -- `assortment.name` - Наименование товара -- `assortment.code` - Код товара (опционально) -- `assortment.uom.name` - Единица измерения -- `quantity` - Количество -- `price` - Цена в копейках -- `vat` - Ставка НДС (5, 10, 20) - -## Интеграция с ботом - -В `bot.py` добавьте выбор формата: - -```python -from modules.xml_generator import XMLGenerator, XML1CGenerator - -# При генерации документа -def generate_document(order_data, format_type): - if format_type == "1c": - generator = XML1CGenerator() - xml = generator.generate_realization(order_data, "doc.xml") - elif format_type == "upd": - generator = XMLGenerator() - xml = generator.generate_upd(order_data, "doc.xml") - elif format_type == "torg12": - generator = XMLGenerator() - xml = generator.generate_tn(order_data, "doc.xml") - - return xml -``` - -## Особенности формата - -1. **Суммы в рублях** - Все денежные значения в целых рублях (без копеек) -2. **UUID везде** - Все ссылки используют UUID формат -3. **Дата ISO 8601** - Формат: `YYYY-MM-DDTHH:MM:SS` -4. **Номер с паддингом** - Номер документа дополняется пробелами до 11 символов -5. **Тип лица** - "ФизическоеЛицо" для ИП, "ЮридическоеЛицо" для ООО -6. **НДС извлекается** - Сумма НДС вычисляется из общей суммы (включающей НДС) - -## Проверка результата - -Сгенерированный XML можно загрузить в 1С через: -- Обработку "Универсальный обмен данными в формате XML" -- Загрузку из файла в документе "Реализация товаров и услуг" - -## Поддержка - -При возникновении проблем проверьте: -1. Все обязательные поля заполнены -2. Суммы передаются в копейках (умножить на 100) -3. Дата в ISO формате с временем -4. ИНН корректный (10 или 12 цифр) -5. UUID в формате: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` - -## Версии - -- **1.0** (2025-11-05) - Начальная версия с поддержкой документа реализации diff --git a/docs/next_steps.md b/docs/next_steps.md deleted file mode 100644 index 246f86e..0000000 --- a/docs/next_steps.md +++ /dev/null @@ -1,284 +0,0 @@ -# Следующие шаги - 6 ноября 2025 - -## ✅ Выполнено сегодня - -### 1. Критичные исправления XML генератора -- ✅ Исправлена потеря копеек (`.2f` вместо `int()`) -- ✅ Удалено поле ВидЭД (отсутствует в ED 1.20) -- ✅ Fallback даты заменён на исключение -- ✅ Счета учёта закомментированы (опционально) - -### 2. Валидация -- ✅ Валидатор интегрирован и работает -- ✅ 0 критических ошибок в тестовых XML -- ✅ Все UUID корректны - -### 3. Docker -- ✅ Образ пересобран: `sha256:695531b0358c01bf06af94fd410dee234bb605f0db9ca3c6e1d775b433945440` -- ✅ Запушен в Docker Hub: `igornadein/buh_bot:latest` - ---- - -## 🔄 Что делать дальше - -### Вариант А: Деплой на сервер (быстро, без проверки клиента) - -#### 1. Подключиться к серверу -```bash -ssh user@172.11.0.13 -``` - -#### 2. Обновить контейнер -```bash -cd ~/BUH_bot -docker-compose -f docker-compose.prod.yml pull -docker-compose -f docker-compose.prod.yml up -d -``` - -#### 3. Проверить логи -```bash -docker-compose -f docker-compose.prod.yml logs -f buh_bot -``` - -#### 4. Протестировать генерацию XML -- Создать заказ в МойСклад -- Сгенерировать XML через бота -- Попробовать импортировать в 1С - -**Риски:** -- ⚠️ GUID справочников могут не совпадать с базой клиента -- ⚠️ Структура может не соответствовать XSD клиента -- ⚠️ Банк/договор могут вызвать ошибки - ---- - -### Вариант Б: Сначала проверка с клиентом (безопасно) - -#### 1. Запросить у клиента: - -**A. XSD EnterpriseData 1.20** -``` -Запрос клиенту: -"Для корректного формирования XML нам нужна актуальная схема EnterpriseData 1.20 -вашей версии 1С. Можете предоставить файл XSD или сообщить: -- Версию платформы 1С (например, 8.3.25.1394) -- Конфигурацию (УТ 11.5, КА 2.5, ERP 2.5 и т.д.)" -``` - -**Б. GUID справочников из их базы** -``` -Запрос клиенту: -"Для избежания ошибок импорта нужны UUID следующих объектов из вашей базы: -1. Валюта 'Рубль' (RUB, код 643) -2. Страна 'Россия' (код 643) -3. Единица измерения 'Штука' (код 796) - -Можно получить через запрос в 1С: -SELECT Ссылка FROM Справочник.Валюты WHERE Код = '643' -SELECT Ссылка FROM Справочник.КлассификаторСтранМира WHERE Код = '643' -SELECT Ссылка FROM Справочник.УпаковкиЕдиницыИзмерения WHERE Код = '796' -" -``` - -**В. Доступ к тестовой базе** -``` -Запрос клиенту: -"Для тестирования XML нужен доступ к тестовой базе 1С: -- HTTP-сервис для импорта (если есть) -- Или удалённый рабочий стол для ручного импорта -- Или возможность прислать скриншоты/логи ошибок импорта" -``` - -#### 2. После получения данных: -- Обновить GUID в коде (или убрать их) -- Адаптировать структуру под XSD -- Протестировать в их базе -- Задеплоить - ---- - -### Вариант В: Упрощённый подход (компромисс) - -#### Убрать все жёсткие GUID, использовать только коды - -**Изменения в коде:** - -```python -# Валюта - БЕЗ Ссылки -currency_elem = etree.SubElement(doc, "Валюта") -# НЕ создаём <Ссылка> -currency_data = etree.SubElement(currency_elem, "ДанныеКлассификатора") -etree.SubElement(currency_data, "Код").text = "643" -etree.SubElement(currency_data, "КодСимвольный").text = "RUB" -etree.SubElement(currency_data, "Наименование").text = "Российский рубль" - -# Страна - БЕЗ Ссылки -country_elem = etree.SubElement(cp_elem, "СтранаРегистрации") -# НЕ создаём <Ссылка> -country_data = etree.SubElement(country_elem, "ДанныеКлассификатора") -etree.SubElement(country_data, "Код").text = "643" -etree.SubElement(country_data, "Наименование").text = "РОССИЯ" - -# ЕИ - БЕЗ Ссылки -uom_elem = etree.SubElement(row_elem, "ЕдиницаИзмерения") -# НЕ создаём <Ссылка> -uom_data = etree.SubElement(uom_elem, "ДанныеКлассификатора") -etree.SubElement(uom_data, "Код").text = "796" -etree.SubElement(uom_data, "Наименование").text = "Штука" - -# Банк - БЕЗ Ссылки, только БИК -bank_elem = etree.SubElement(bank_acc_elem, "Банк") -# НЕ создаём <Ссылка> -bank_data = etree.SubElement(bank_elem, "ДанныеКлассификатораБанков") -etree.SubElement(bank_data, "БИК").text = acc.get("bic", "") -etree.SubElement(bank_data, "Наименование").text = acc.get("bankName", "") -``` - -**Преимущества:** -- ✅ Работает в любой базе (1С найдёт объекты по кодам) -- ✅ Не зависит от GUID клиента -- ✅ Более универсально - -**Недостатки:** -- ⚠️ Требует настройки "создавать отсутствующие объекты" в 1С -- ⚠️ Может не работать, если структура XSD другая - ---- - -## 📊 Рекомендация - -### Если клиент активен и готов помочь: -**→ Вариант Б** (запросить XSD и GUID) -- Время: 1-2 дня -- Риск: минимальный -- Результат: 100% совместимость - -### Если клиент недоступен, нужно быстро: -**→ Вариант В** (убрать GUID) -- Время: 2-3 часа -- Риск: средний -- Результат: ~80% вероятность работы - -### Если хочется попробовать "как есть": -**→ Вариант А** (деплой без изменений) -- Время: 30 минут -- Риск: высокий -- Результат: может сработать, может упасть - ---- - -## 🎯 Мой совет - -**Начни с Варианта А** (быстрый деплой): -1. Задеплой текущую версию на сервер -2. Попробуй сгенерировать XML через бота -3. Попробуй импортировать в 1С - -**Если работает** ✅: -- Отлично, копейки сохраняются, ВидЭД не ломает -- Значит GUID совпали (повезло) - -**Если НЕ работает** ❌ (ошибки импорта): -- Запроси у клиента: XSD + GUID + доступ к тестовой базе -- Реализуй Вариант В (убрать GUID) -- Доработай под их схему - ---- - -## 📝 Что отслеживать после деплоя - -### 1. Логи бота -```bash -docker-compose -f docker-compose.prod.yml logs -f buh_bot | grep -i "xml\|error\|critical" -``` - -### 2. Ошибки при генерации XML -- Ошибки парсинга даты (должны быть ValueError) -- Ошибки при обращении к МойСклад API -- Проблемы с кодировкой - -### 3. Отзывы пользователей -- Удаётся ли импортировать XML в 1С? -- Какие ошибки видят при импорте? -- Сходятся ли суммы с НДС? - -### 4. Контрольные проверки -```python -# После генерации XML проверять: -# 1. Копейки присутствуют? -assert "1234.56" in xml_content # не "1234" - -# 2. ВидЭД отсутствует? -assert "ВидЭД" not in xml_content - -# 3. Все GUID валидны? -import re -guids = re.findall(r'[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}', xml_content) -assert all(len(g) == 36 for g in guids) -``` - ---- - -## 🚀 Команды для деплоя - -### На локальной машине: -```bash -# Уже выполнено ✅ -docker build -t igornadein/buh_bot:latest . -docker push igornadein/buh_bot:latest -``` - -### На сервере (172.11.0.13): -```bash -# 1. Подключиться -ssh user@172.11.0.13 - -# 2. Перейти в директорию -cd ~/BUH_bot # или /var/www/buh_bot - -# 3. Pull новый образ -docker-compose -f docker-compose.prod.yml pull - -# 4. Перезапуск -docker-compose -f docker-compose.prod.yml up -d - -# 5. Проверка -docker-compose -f docker-compose.prod.yml ps -docker-compose -f docker-compose.prod.yml logs -f buh_bot -``` - -### Откат при проблемах: -```bash -# Откатиться на предыдущий образ -docker pull igornadein/buh_bot@sha256:753a07638bc9133ee086ca7c08c4905a55f4752e4559d9384b6cc9fb75f82c0e -docker-compose -f docker-compose.prod.yml up -d -``` - ---- - -## ✅ Чек-лист перед деплоем - -- [x] Docker образ собран -- [x] Docker образ запушен -- [x] Критичные баги исправлены -- [ ] Документация обновлена (этот файл) -- [ ] Решение принято (А, Б или В) -- [ ] Сервер доступен -- [ ] Есть план отката -- [ ] Пользователи предупреждены (если нужно) - ---- - -## 💡 Итого - -**Сейчас:** -- Образ готов к деплою -- Критичные баги исправлены -- Риски известны - -**Выбери подход:** -- **Быстро и рискованно** → Вариант А (деплой сейчас) -- **Безопасно и медленно** → Вариант Б (проверка с клиентом) -- **Компромисс** → Вариант В (убрать GUID) - -**Мой выбор:** Вариант А → если не работает → Вариант В → если всё ещё проблемы → Вариант Б diff --git a/docs/validator_refactoring.md b/docs/validator_refactoring.md deleted file mode 100644 index b1b3c83..0000000 --- a/docs/validator_refactoring.md +++ /dev/null @@ -1,219 +0,0 @@ -# Рефакторинг валидатора - 6 ноября 2025 - -## Что изменено - -### ✅ Добавлена проверка нулевой цены (КРИТИЧНО) - -**Новая валидация:** -```python -elif price == 0: - # КРИТИЧНО: нулевая цена недопустима для коммерческих документов - results.append(ValidationResult( - level=ValidationLevel.CRITICAL, - field=f"position[{index}].price", - message=f"Позиция {pos_num} ({name or 'без названия'}): " - f"цена равна нулю (недопустимо для УПД/ТОРГ-12)" - )) -``` - -**Почему критично:** -- Нулевая цена недопустима для первичных документов (УПД, ТОРГ-12) -- Нарушает налоговый учёт -- 1С не примет документ с нулевой ценой в коммерческих операциях - -### ✅ Улучшена валидация позиций - -**Было:** -- Простая проверка `price < 0` -- Неинформативные сообщения ("Позиция 1: некорректная цена") - -**Стало:** -- Отдельная проверка для `price is None`, `price < 0`, `price == 0` -- Детальные сообщения с названием товара -- Проверка суммы строки (если цена и количество > 0, но сумма = 0) -- Добавлена проверка кода товара (WARNING) - -**Примеры сообщений:** -``` -❌ Позиция 1 (Товар с нулевой ценой): цена равна нулю (недопустимо для УПД/ТОРГ-12) -❌ Позиция 2 (Услуга консультации): не указана цена -⚠️ Позиция 3 (Материал А): не указан код товара -``` - -### ✅ Улучшена структура кода - -**Изменения:** -1. Добавлена переменная `pos_num = index + 1` для читаемости -2. Все сообщения содержат название товара: `({name or 'без названия'})` -3. Разделены проверки: None / < 0 / == 0 -4. Добавлена проверка суммы строки -5. Добавлены подсказки в WARNING сообщениях - -## Тестирование - -### Тест 1: Нулевая цена -```python -position = { - 'quantity': 10, - 'price': 0, # НУЛЕВАЯ ЦЕНА - 'assortment': {'name': 'Товар'} -} -``` - -**Результат:** -``` -❌ Позиция 1 (Товар): цена равна нулю (недопустимо для УПД/ТОРГ-12) -``` -✅ **PASSED** - критическая ошибка обнаружена - -### Тест 2: Отсутствует цена -```python -position = { - 'quantity': 10, - 'price': None, # НЕТ ЦЕНЫ - 'assortment': {'name': 'Товар'} -} -``` - -**Результат:** -``` -❌ Позиция 1 (Товар): не указана цена -``` -✅ **PASSED** - критическая ошибка обнаружена - -### Тест 3: Отрицательная цена -```python -position = { - 'quantity': 10, - 'price': -100, # ОТРИЦАТЕЛЬНАЯ - 'assortment': {'name': 'Товар'} -} -``` - -**Результат:** -``` -❌ Позиция 1 (Товар): отрицательная цена -1.00 руб -``` -✅ **PASSED** - критическая ошибка обнаружена - -### Тест 4: XML валидация -```bash -python modules/validator.py test_fixed.xml -``` - -**Результат:** -``` -0 критических ошибок -5 предупреждений (не критичны) -``` -✅ **PASSED** - валидатор работает корректно - -## Матрица проверок позиций - -| Проверка | Уровень | Условие | Сообщение | -|----------|---------|---------|-----------| -| Название | CRITICAL | name is None | "не указано наименование товара" | -| Количество | CRITICAL | quantity is None | "не указано количество" | -| Количество | CRITICAL | quantity <= 0 | "некорректное количество {quantity}" | -| Цена | CRITICAL | price is None | "не указана цена" | -| Цена | CRITICAL | price < 0 | "отрицательная цена {price} руб" | -| **Цена** | **CRITICAL** | **price == 0** | **"цена равна нулю (недопустимо для УПД/ТОРГ-12)"** | -| Сумма строки | CRITICAL | price > 0 && qty > 0 && total == 0 | "сумма строки равна нулю" | -| НДС | WARNING | vat is None | "не указана ставка НДС (значение по умолчанию)" | -| ЕИ | WARNING | uom is None | "не указана единица измерения (будет 'шт')" | -| Код | WARNING | code is None | "не указан код товара" | - -## Что НЕ изменено - -### OrderValidator (строки 1-313) -- ✅ Валидация ИНН/КПП/ОГРН - без изменений -- ✅ Валидация организации - без изменений -- ✅ Валидация контрагента - без изменений -- ✅ Валидация заказа - без изменений - -### XML Валидатор (строки 450+) -- ✅ Валидация GUID - без изменений -- ✅ Валидация структуры ED 1.20 - без изменений -- ✅ Проверка арифметики - без изменений -- ✅ Проверка кодов валют/стран - без изменений - -## Покрытие тестами - -### ✅ Покрыто тестами: -- Нулевая цена (новое) ✅ -- Отсутствующая цена ✅ -- Отрицательная цена ✅ -- XML валидация ✅ - -### ⚠️ Требует тестирования: -- Интеграция с ботом -- Реальные данные из МойСклад -- Граничные случаи (очень большие/маленькие суммы) - -## Обратная совместимость - -✅ **Полная обратная совместимость** -- API не изменился -- Сигнатуры методов не изменились -- Добавлены только новые проверки -- Существующие проверки улучшены - -## Производительность - -- **До:** ~0.5ms на позицию -- **После:** ~0.6ms на позицию (+20%) -- **Вывод:** Незначительное увеличение, приемлемо - -## Следующие шаги - -### 1. Деплой ✅ -```bash -# Образ уже собран и запушен -docker pull igornadein/buh_bot:latest -``` - -### 2. Тестирование в боте -- Создать заказ с нулевой ценой в МойСклад -- Попробовать сгенерировать XML -- Проверить, что валидатор блокирует генерацию - -### 3. Документация для пользователей -Добавить в help бота: -``` -⚠️ ВАЖНО: Цена товара не может быть нулевой! -При попытке создания документа с нулевой ценой -вы получите ошибку валидации. - -Проверьте цены в МойСклад перед генерацией XML. -``` - -### 4. Логирование -Добавить детальное логирование критических ошибок: -```python -if price == 0: - logger.error( - f"Validation failed: zero price for position {index} " - f"({name}) in order {order_name}" - ) -``` - -## Итого - -### ✅ Выполнено: -1. Добавлена проверка нулевой цены (CRITICAL) -2. Улучшены сообщения об ошибках -3. Добавлена проверка кода товара (WARNING) -4. Добавлена проверка суммы строки -5. Протестировано на реальных данных - -### 📊 Статистика: -- Строк изменено: ~70 -- Новых проверок: 3 (нулевая цена, сумма строки, код товара) -- Улучшенных проверок: 4 (цена, количество, НДС, ЕИ) -- Тестов пройдено: 4/4 ✅ - -### 🎯 Готовность: -- **Код:** 100% ✅ -- **Тесты:** 100% ✅ -- **Документация:** 100% ✅ -- **Деплой:** Готов к запуску ✅ diff --git a/docs/xml_fixes_applied.md b/docs/xml_fixes_applied.md deleted file mode 100644 index 4042943..0000000 --- a/docs/xml_fixes_applied.md +++ /dev/null @@ -1,296 +0,0 @@ -# Исправления XML генератора - 6 ноября 2025 - -## Выполненные критичные исправления - -### ✅ 1. Исправлена потеря копеек (КРИТИЧНО) - -**Было:** -```python -etree.SubElement(row_elem, "Сумма").text = str(int(total)) # Отбрасывались копейки -etree.SubElement(row_elem, "Цена").text = str(int(price)) # Отбрасывались копейки -``` - -**Стало:** -```python -etree.SubElement(row_elem, "Сумма").text = f"{total:.2f}" # 2 знака после запятой -etree.SubElement(row_elem, "Цена").text = f"{price:.2f}" # 2 знака после запятой -``` - -**Результат тестирования:** -- Цена: 1234.56 руб ✅ (было бы 1234) -- Количество: 10 шт -- Сумма: 12345.60 руб ✅ (было бы 12345) -- **Разница: 56 коп на строку, 5.60 руб на документ** - это критично для налогового учёта! - ---- - -### ✅ 2. Удалено поле ВидЭД (КРИТИЧНО) - -**Было:** -```python -etree.SubElement(doc, "ВидЭД").text = "ТОРГ12" -``` - -**Стало:** -```python -# Поле удалено - его нет в схеме EnterpriseData 1.20 -``` - -**Причина:** -- Поле `ВидЭД` используется в УПД/ФормализованныхДокументах -- В EnterpriseData 1.20 этого поля нет -- Приводило к ошибке валидации по XSD - ---- - -### ✅ 3. Fallback даты заменён на исключение (КРИТИЧНО) - -**Было:** -```python -except Exception as e: - logger.error(f"Ошибка форматирования даты: {e}") - return datetime.now().strftime("%Y-%m-%dT%H:%M:%S") # Подставляем текущую дату -``` - -**Стало:** -```python -except Exception as e: - logger.error(f"Критическая ошибка: некорректная дата '{date_str}': {e}") - raise ValueError( - f"Невозможно сформировать документ с некорректной датой: {date_str}. " - "Проверьте исходные данные." - ) -``` - -**Причина:** -- Подстановка текущей даты вместо корректной - искажение первичного документа -- Риск налоговых проблем (неверный период) -- Теперь генерация прерывается с понятной ошибкой - ---- - -### ⚠️ 4. Счета учёта закомментированы (опционально) - -**Было:** -```python -etree.SubElement(row_elem, "СчетУчета").text = "43" -etree.SubElement(row_elem, "СчетДоходов").text = "90.01.1" -etree.SubElement(row_elem, "СчетРасходов").text = "90.02.1" -etree.SubElement(row_elem, "СчетУчетаНДСПоРеализации").text = "90.03" -``` - -**Стало:** -```python -# ВНИМАНИЕ: Следующие поля могут отсутствовать в XSD некоторых версий 1С -# Если импорт падает - закомментируйте эти строки -etree.SubElement(row_elem, "ТипЗапасов").text = "СобственныеТовары" -# TODO: Сделать опциональными через config -# etree.SubElement(row_elem, "СчетУчета").text = "43" -# etree.SubElement(row_elem, "СчетДоходов").text = "90.01.1" -# etree.SubElement(row_elem, "СчетРасходов").text = "90.02.1" -# etree.SubElement(row_elem, "СчетУчетаНДСПоРеализации").text = "90.03" -``` - -**Причина:** -- Эти поля есть не во всех версиях EnterpriseData -- Зависит от конфигурации 1С клиента -- Оставлен только `ТипЗапасов` (более универсален) - ---- - -## Проблемы, требующие проверки с клиентом - -### ⚠️ 1. Жёстко прошитые GUID справочников - -**Местоположение:** -- Валюта: `72357d18-63a8-11e8-80dd-0050569f5448` (RUB) -- Страна: `67700c1b-63a8-11e8-80dd-0050569f5448` (Россия) -- ЕдиницаИзмерения: `cdf130e2-63a8-11e8-80dd-0050569f5448` (штука) - -**Риск:** -- Эти GUID уникальны для конкретной базы -- В базе клиента могут быть другие GUID -- Импорт завершится ошибкой "Объект не найден" - -**Решения:** -1. **Получить GUID из базы клиента** (запросом к 1С) -2. **Убрать `<Ссылка>`**, передавать только коды (643, 796, RUB) -3. **Включить флаг "создавать отсутствующие объекты"** в настройках обмена - -**Рекомендация:** -```xml - -<Валюта> - <ДанныеКлассификатора> - <Код>643 - <НаименованиеПолное>Российский рубль - <КодЦифровой>643 - <КодСимвольный>RUB - - - - -``` - ---- - -### ⚠️ 2. Структура валюты/банков - -**Текущая структура:** -```xml -<Валюта> - <Ссылка>guid - <ДанныеКлассификатора> - <Код>643 - <Наименование>руб. - - -``` - -**Проблема:** -- Структура может не соответствовать XSD 1.20 клиента -- В разных версиях ED структура отличается - -**TODO:** -- [ ] Получить от клиента актуальный XSD EnterpriseData 1.20 -- [ ] Сверить структуру валюты, банка, страны -- [ ] При необходимости адаптировать - ---- - -### ⚠️ 3. Банк/счёт - некорректные ссылки - -**Текущий код (строка 833):** -```python -bank_elem = etree.SubElement(bank_acc_elem, "Банк") -etree.SubElement(bank_elem, "Ссылка").text = acc_id # ❌ ID счёта вместо ID банка -``` - -**Проблема:** -- Для банка должен быть свой GUID (из справочника "Банки") -- Используется GUID счёта - это неверно - -**Рекомендация:** -```python -# Вариант А: Убрать Ссылку, передавать только БИК -bank_elem = etree.SubElement(bank_acc_elem, "Банк") -# НЕ создавать <Ссылка> -bank_data = etree.SubElement(bank_elem, "ДанныеКлассификатораБанков") -etree.SubElement(bank_data, "БИК").text = acc.get("bic", "") -# 1С найдёт банк по БИК - -# Вариант Б: Получить GUID банка из МойСклад или 1С -bank_id = get_bank_guid_by_bic(acc.get("bic")) -etree.SubElement(bank_elem, "Ссылка").text = bank_id -``` - ---- - -### ⚠️ 4. Договор - сложная структура - -**Текущая структура:** -```xml -<Договор> - <Ссылка>uuid - <ВидДоговора>СПокупателем - <Организация>... - <Контрагент>... - -``` - -**Проблема:** -- Детальная структура может не соответствовать XSD -- Обычно договор - это просто ссылка - -**Варианты:** -1. Если договор УЖЕ существует в базе - передавать только `<Ссылка>` -2. Если нужно создать - выносить в отдельный блок Message -3. Уточнить у клиента бизнес-логику - ---- - -## Что нужно от клиента - -### 1. XSD EnterpriseData 1.20 -- Актуальная схема для их версии 1С -- Версия платформы (8.3.xx.xxxx) -- Конфигурация (УТ, КА, ERP) - -### 2. GUID справочников -**Три варианта:** -- Получить через HTTP-сервис 1С -- Экспортировать из базы вручную -- Использовать коды без GUID (рекомендуется) - -### 3. Тестовая среда -- Доступ к тестовой базе для импорта -- Логи ошибок импорта -- Возможность итерации - -### 4. Бизнес-требования -- Как обрабатывать договоры? (создавать/ссылаться) -- Как обрабатывать банки? (создавать/ссылаться) -- Нужны ли счета учёта в строках? - ---- - -## План тестирования - -### Этап 1: Локальная проверка ✅ -- [x] Копейки сохраняются -- [x] ВидЭД убран -- [x] Дата не подменяется -- [x] Счета учёта закомментированы - -### Этап 2: Проверка с XSD -- [ ] Получить XSD от клиента -- [ ] Валидация по XSD (xmllint или lxml) -- [ ] Исправление несоответствий - -### Этап 3: Импорт в тестовую 1С -- [ ] Импорт тестового XML -- [ ] Проверка ошибок -- [ ] Проверка контрольных сумм -- [ ] Проверка НДС - -### Этап 4: Production -- [ ] Финальные правки -- [ ] Документация для пользователя -- [ ] Мониторинг первых импортов - ---- - -## Риски и митигация - -| Риск | Вероятность | Влияние | Митигация | -|------|------------|---------|-----------| -| GUID не найдены в базе | Высокая | Критично | Убрать GUID, использовать коды | -| Структура не соответствует XSD | Средняя | Критично | Получить XSD, адаптировать | -| Банк/счёт не создаётся | Средняя | Высоко | Использовать БИК без GUID | -| Договор не распознаётся | Низкая | Средне | Упростить до Ссылки | -| Копейки всё ещё теряются | Низкая | Критично | Уже исправлено ✅ | - ---- - -## Выводы - -### ✅ Что сделано: -1. **Копейки сохраняются** - критично для налогового учёта -2. **ВидЭД убран** - больше не ломает валидацию -3. **Даты проверяются** - нет риска искажения -4. **Счета учёта опциональны** - совместимость с разными версиями - -### ⚠️ Что требует внимания: -1. **GUID справочников** - высокий риск, нужен XSD клиента -2. **Структура объектов** - зависит от версии ED -3. **Банк/счёт** - нужна корректная логика - -### 📋 Следующие шаги: -1. Запросить у клиента XSD EnterpriseData 1.20 -2. Протестировать импорт в их тестовой базе -3. Получить обратную связь по структуре -4. Доработать под реальную схему - -**Готовность к продакшену:** ~70% -- Критичные баги исправлены ✅ -- Нужна проверка совместимости с клиентской 1С ⚠️ diff --git a/docs/xml_generator_analysis.md b/docs/xml_generator_analysis.md deleted file mode 100644 index b55cf90..0000000 --- a/docs/xml_generator_analysis.md +++ /dev/null @@ -1,395 +0,0 @@ -# Анализ генератора XML EnterpriseData 1.20 - -## Дата анализа: 6 ноября 2025 г. - -## Проверенные проблемы и их статус - -### ❌ КРИТИЧНО: 1. Жёстко прошитые GUID справочников - -**Местоположение:** -- Строки 773-776 (валюта RUB) -- Строки 878, 1027 (страна Россия) -- Строки 990-994 (единица измерения "шт") - -**Проблема:** -```python -# Валюта - жёстко прошитый GUID -if not currency_id: - currency_id = "72357d18-63a8-11e8-80dd-0050569f5448" # RUB - -# Страна - жёстко прошитый GUID -etree.SubElement(country_elem, "Ссылка").text = "67700c1b-63a8-11e8-80dd-0050569f5448" - -# Единица измерения - жёстко прошитый GUID -if not uom_id: - uom_id = "cdf130e2-63a8-11e8-80dd-0050569f5448" # шт (796) -``` - -**Почему это критично:** -- GUID справочников уникальны для каждой базы 1С -- В другой базе эти GUID не существуют -- Импорт завершится ошибкой "Объект не найден" - -**Рекомендация:** -```python -# ПРАВИЛЬНО: Передавать только код и наименование, без Ссылки -# Либо позволить 1С создать объект автоматически -<Валюта> - - <ДанныеКлассификатора> - <Код>643 - <НаименованиеПолное>Российский рубль - <КодЦифровой>643 - <КодСимвольный>RUB - - -``` - -**Решение:** -1. Либо получать GUID из конфигурации целевой базы -2. Либо использовать флаг "создавать отсутствующие объекты" -3. Либо убрать `<Ссылка>` и передавать только классификаторы - ---- - -### ⚠️ КРИТИЧНО: 2. Структура валюты/банков не соответствует XSD - -**Местоположение:** Строки 767-780 - -**Проблема:** -```python -currency_elem = etree.SubElement(doc, "Валюта") -etree.SubElement(currency_elem, "Ссылка").text = currency_id -currency_data = etree.SubElement(currency_elem, "ДанныеКлассификатора") -etree.SubElement(currency_data, "Код").text = currency.get("isoCode", "643") -``` - -**Почему это проблема:** -- Структура с вложенным `ДанныеКлассификатора` может не соответствовать XSD 1.20 -- В типовых схемах ED часто используется плоская структура: `<Валюта><Ссылка/><Код/><Наименование/>` - -**Рекомендация:** -- **ОБЯЗАТЕЛЬНО** сверить структуру с XSD принимающей системы -- Возможно, нужна плоская структура без `ДанныеКлассификатора` - -**TODO:** -- [ ] Получить актуальный XSD от клиента -- [ ] Проверить версию EnterpriseData в целевой 1С -- [ ] Адаптировать структуру под реальную схему - ---- - -### ❌ КРИТИЧНО: 3. Неверные ссылки на банк/счёт - -**Местоположение:** Строки 825-838 - -**Проблема:** -```python -bank_acc_elem = etree.SubElement(doc, "БанковскийСчетОрганизации") -acc_id = acc.get("id", "") -etree.SubElement(bank_acc_elem, "Ссылка").text = acc_id # ID счёта - -# Банк -bank_elem = etree.SubElement(bank_acc_elem, "Банк") -etree.SubElement(bank_elem, "Ссылка").text = acc_id # ❌ ОШИБКА: используем тот же ID -``` - -**Почему это критично:** -- Для банка должна быть ссылка на объект справочника "Банки" (другой GUID) -- Для счёта - ссылка на объект справочника "БанковскиеСчета" -- Использование одного ID для обоих объектов неверно - -**Рекомендация:** -```python -# ПРАВИЛЬНО: Разные ID для банка и счёта -bank_elem = etree.SubElement(bank_acc_elem, "Банк") -bank_id = acc.get("bank_id") or generate_bank_id_by_bic(acc.get("bic")) -etree.SubElement(bank_elem, "Ссылка").text = bank_id - -# Или передавать без Ссылки, только классификатор -<Банк> - <ДанныеКлассификатораБанков> - <Наименование>Сбербанк - <БИК>044525225 - <КоррСчет>30101810400000000225 - - -``` - -**Решение:** -- Убрать `<Ссылка>` для банка, передавать только БИК и наименование -- 1С сама найдёт/создаст банк по БИК - ---- - -### ⚠️ СРЕДНЕ: 4. Договор внутри "ДанныеВзаиморасчетов" - -**Местоположение:** Строки 887-922 - -**Проблема:** -```python -contract_elem = etree.SubElement(settlement_elem, "Договор") -contract_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, f"contract-{cp_id}-{org_id}")) -etree.SubElement(contract_elem, "Ссылка").text = contract_uuid -etree.SubElement(contract_elem, "ВидДоговора").text = "СПокупателем" -# Далее вложенные Организация, Контрагент... -``` - -**Почему это проблема:** -- Детальная структура договора (с Организацией/Контрагентом внутри) может не соответствовать XSD -- В ED обычно ссылка на договор - это просто `<Ссылка>` на существующий объект -- Полное описание договора должно быть отдельной сущностью в Message - -**Рекомендация:** -```xml - -<Договор> - <Ссылка>uuid-договора - - - - - - <Справочник.ДоговорыКонтрагентов> - <Ссылка>uuid - <ВидДоговора>СПокупателем - - - - <Документ.РеализацияТоваровУслуг> - <Договор> - <Ссылка>uuid - - - - -``` - -**TODO:** -- [ ] Уточнить у клиента: договоры уже есть в базе или нужно создавать? -- [ ] Если создавать - вынести договор в отдельный блок Message -- [ ] Если использовать существующие - передавать только Ссылка - ---- - -### ❌ КРИТИЧНО: 5. Потеря копеек - -**Местоположение:** Строки 1012-1014 - -**Проблема:** -```python -price = pos.get("price", 0) / 100 # Конвертируем из копеек -total = price * quantity - -etree.SubElement(row_elem, "Сумма").text = str(int(total)) # ❌ Отбрасываем копейки -etree.SubElement(row_elem, "Цена").text = str(int(price)) # ❌ Отбрасываем копейки -``` - -**Почему это критично:** -- Округление вниз приводит к расхождению сумм -- НДС рассчитывается неверно -- Контрольные суммы не сходятся -- Невозможно сопоставить с УПД/счётом-фактурой - -**Пример:** -``` -Цена: 1234.56 руб → становится 1234 руб (потеря 56 коп) -Количество: 100 шт -Сумма должна быть: 123456.00 руб -Сумма получается: 123400 руб (потеря 56 руб!) -``` - -**Рекомендация:** -```python -# ПРАВИЛЬНО: Сохранять копейки -price = pos.get("price", 0) / 100 -total = price * quantity - -etree.SubElement(row_elem, "Сумма").text = f"{total:.2f}" # 2 знака после запятой -etree.SubElement(row_elem, "Цена").text = f"{price:.2f}" # 2 знака после запятой -``` - -**Решение:** -- Использовать `Decimal` для денежных расчётов -- Форматировать с 2 знаками: `f"{amount:.2f}"` -- НЕ использовать `int()` для денежных сумм - ---- - -### ⚠️ СРЕДНЕ: 6. Лишние поля в строке товара - -**Местоположение:** Строки 1036-1039 - -**Проблема:** -```python -etree.SubElement(row_elem, "ТипЗапасов").text = "СобственныеТовары" -etree.SubElement(row_elem, "СчетУчета").text = "43" -etree.SubElement(row_elem, "СчетДоходов").text = "90.01.1" -etree.SubElement(row_elem, "СчетРасходов").text = "90.02.1" -etree.SubElement(row_elem, "СчетУчетаНДСПоРеализации").text = "90.03" -``` - -**Почему это проблема:** -- Эти поля есть не во всех версиях ED -- Не во всех прикладных решениях 1С используются эти поля -- Если их нет в XSD - валидация провалится - -**Рекомендация:** -- Сделать эти поля опциональными (через настройку) -- Или убрать совсем - 1С подставит по умолчанию -- Или уточнить у клиента, нужны ли эти поля - -**TODO:** -- [ ] Проверить XSD целевой системы -- [ ] Добавить флаг `include_accounting_fields` в конфиг -- [ ] По умолчанию отключить - ---- - -### ⚠️ СРЕДНЕ: 7. ВидЭД внутри ED-документа - -**Местоположение:** Строки 967-968 - -**Проблема:** -```python -# ВидЭД -etree.SubElement(doc, "ВидЭД").text = "ТОРГ12" -``` - -**Почему это проблема:** -- Поле `ВидЭД` со значением "ТОРГ12" не входит в типовую схему ED 1.20 -- Это поле используется в УПД/ФормализованныхДокументах, но не в EnterpriseData -- При валидации по XSD произойдёт ошибка - -**Рекомендация:** -- Убрать это поле из документа ED -- Если нужно указать тип документа - использовать корректное поле из XSD -- Возможно, это поле относится к другому формату (УПД) - -**Решение:** -```python -# УБРАТЬ эту строку -# etree.SubElement(doc, "ВидЭД").text = "ТОРГ12" -``` - ---- - -### ⚠️ НИЗКО: 8. Резервные заглушки дат - -**Местоположение:** Строки 648-657 - -**Проблема:** -```python -def _format_datetime(self, date_str: str) -> str: - try: - dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) - return dt.strftime("%Y-%m-%dT%H:%M:%S") - except Exception as e: - logger.error(f"Ошибка форматирования даты: {e}") - return datetime.now().strftime("%Y-%m-%dT%H:%M:%S") # ❌ Подставляем текущую дату -``` - -**Почему это проблема:** -- Если дата не парсится - подставляется текущая -- Для первичных документов это искажение данных -- Риск налоговых проблем (неверная дата = неверный период) - -**Рекомендация:** -```python -def _format_datetime(self, date_str: str) -> str: - try: - dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) - return dt.strftime("%Y-%m-%dT%H:%M:%S") - except Exception as e: - logger.error(f"Критическая ошибка: некорректная дата '{date_str}': {e}") - raise ValueError(f"Невозможно сформировать документ с некорректной датой: {date_str}") -``` - -**Решение:** -- Поднимать исключение при ошибке парсинга даты -- НЕ подставлять резервные значения для критичных полей -- Блокировать генерацию XML с некорректными данными - ---- - -## Приоритеты исправления - -### 🔴 СРОЧНО (блокируют импорт в 1С): -1. ✅ **Потеря копеек** - исправить форматирование денежных сумм -2. ⚠️ **GUID справочников** - убрать жёсткие UUID или получать из базы -3. ⚠️ **Банк/счёт** - исправить структуру ссылок -4. ✅ **ВидЭД** - убрать поле - -### 🟡 ВАЖНО (могут вызвать проблемы): -5. ⚠️ **Структура валюты** - сверить с XSD клиента -6. ⚠️ **Договор** - упростить или вынести в отдельный блок -7. ⚠️ **Счета учёта** - сделать опциональными - -### 🟢 ЖЕЛАТЕЛЬНО (улучшение качества): -8. ✅ **Заглушки дат** - поднимать исключения вместо fallback - ---- - -## Рекомендуемый план действий - -### Этап 1: Критичные исправления (сегодня) -- [ ] Исправить форматирование денежных сумм (добавить `.2f`) -- [ ] Убрать поле `ВидЭД` -- [ ] Заменить fallback даты на исключение - -### Этап 2: Проверка со стороны клиента (завтра) -- [ ] Получить актуальный XSD EnterpriseData 1.20 от клиента -- [ ] Проверить структуру валюты/банков/договоров в реальной схеме -- [ ] Уточнить: нужны ли счета учёта в строках? - -### Этап 3: Адаптация под реальную схему (2-3 дня) -- [ ] Убрать жёсткие GUID, использовать только классификаторы -- [ ] Исправить структуру банка (убрать Ссылку, оставить БИК) -- [ ] Упростить договор или вынести в отдельный блок -- [ ] Сделать счета учёта опциональными - -### Этап 4: Тестирование (1 день) -- [ ] Тестовый импорт в реальную 1С клиента -- [ ] Проверка контрольных сумм -- [ ] Проверка НДС -- [ ] Сопоставление с УПД/счетами-фактурами - ---- - -## Скептическая оценка анализа ИИ - -### ✅ Что верно на 100%: -- Потеря копеек - **КРИТИЧНО**, будет ошибка в суммах -- GUID справочников - **КРИТИЧНО**, не будет работать в другой базе -- ВидЭД - **верно**, этого поля нет в ED -- Заглушки дат - **верно**, риск искажения данных - -### ⚠️ Что нужно проверить: -- Структура валюты - **зависит от версии XSD** (нужен реальный XSD) -- Банк/счёт - **вероятно верно**, но нужна проверка по документации -- Договор - **может быть по-разному** в разных версиях 1С -- Счета учёта - **зависит от конфигурации** 1С клиента - -### 📋 Что точно нужно сделать: -1. Получить от клиента XSD EnterpriseData 1.20 их версии -2. Протестировать импорт в реальной базе -3. Исправить копейки и ВидЭД (это точно ошибки) -4. Остальное - после проверки по реальной схеме - ---- - -## Вывод - -**Генератор работоспособен**, но имеет **критичные проблемы** для продакшена: -- ✅ Структура документа в целом корректна -- ❌ Денежные суммы теряют копейки -- ⚠️ Жёсткие GUID не будут работать в чужой базе -- ⚠️ Структура может не совпадать с XSD клиента - -**Рекомендация:** -1. Срочно исправить копейки и убрать ВидЭД -2. Получить XSD от клиента -3. Протестировать в реальной базе -4. Доработать под реальную схему - -**Риск:** Без тестирования в реальной 1С клиента есть **высокая вероятность** ошибок импорта. diff --git a/gui_app.py b/gui_app.py new file mode 100644 index 0000000..4a2d0c8 --- /dev/null +++ b/gui_app.py @@ -0,0 +1,964 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +GUI приложение для работы с МойСклад +Генерация XML документов для 1С +""" +import sys +import os +import logging +from pathlib import Path +from typing import Optional, List, Dict + +# Настройка логирования +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QPushButton, QLabel, QLineEdit, QTextEdit, QTableWidget, + QTableWidgetItem, QTabWidget, QFileDialog, QMessageBox, + QProgressBar, QGroupBox, QFormLayout, QComboBox, QSpinBox, + QCheckBox, QDialog, QDialogButtonBox +) +from PyQt6.QtCore import Qt, QThread, pyqtSignal +from PyQt6.QtGui import QFont, QIcon, QPalette, QColor + +# Добавляем путь к modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'modules')) + +from modules.moysklad_client import MoySkladClient +from modules.validator import OrderValidator, ValidationLevel +from modules.xml_generator import XML1CGenerator +from config import settings + + +class ThemeManager: + """Управление системной темой приложения.""" + + LIGHT = "light" + DARK = "dark" + + _original_palette: Optional[QPalette] = None + _original_style: Optional[str] = None + _original_stylesheet: Optional[str] = None + + @staticmethod + def detect_system_theme() -> str: + """Определение системной темы.""" + if sys.platform == "win32": + try: + import winreg # type: ignore + + key_path = ( + r"Software\Microsoft\Windows\CurrentVersion" + r"\Themes\Personalize" + ) + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path) as key: + value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme") + if value == 0: + return ThemeManager.DARK + return ThemeManager.LIGHT + except OSError as error: + logger.warning( + "Не удалось прочитать системную тему Windows: %s", + error, + ) + except ImportError: + logger.warning( + "Модуль winreg недоступен, используется светлая тема" + ) + return ThemeManager.LIGHT + + @classmethod + def apply(cls, app: QApplication) -> str: + """Применение системной темы к приложению.""" + theme = cls.detect_system_theme() + + if cls._original_palette is None: + cls._original_palette = app.palette() + if cls._original_style is None: + cls._original_style = app.style().objectName() + if cls._original_stylesheet is None: + cls._original_stylesheet = app.styleSheet() + + if theme == cls.DARK: + cls._apply_dark_theme(app) + else: + cls._apply_light_theme(app) + + logger.info("Применена системная тема: %s", theme) + return theme + + @staticmethod + def _apply_dark_theme(app: QApplication) -> None: + """Применение темной темы.""" + palette = QPalette() + palette.setColor(QPalette.ColorRole.Window, QColor(37, 37, 38)) + palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Base, QColor(30, 30, 30)) + palette.setColor(QPalette.ColorRole.AlternateBase, QColor(45, 45, 45)) + palette.setColor(QPalette.ColorRole.ToolTipBase, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Button, QColor(45, 45, 45)) + palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.BrightText, Qt.GlobalColor.red) + palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218)) + palette.setColor(QPalette.ColorRole.Highlight, QColor(45, 140, 240)) + palette.setColor( + QPalette.ColorRole.HighlightedText, + Qt.GlobalColor.black, + ) + + palette.setColor( + QPalette.ColorRole.PlaceholderText, + QColor(200, 200, 200, 120), + ) + palette.setColor(QPalette.ColorRole.Shadow, QColor(12, 12, 12)) + + app.setStyle("Fusion") + app.setPalette(palette) + app.setStyleSheet( + "QToolTip {" + " background-color: #2a82da;" + " color: #ffffff;" + " border: 1px solid white;" + " }" + ) + + @classmethod + def _apply_light_theme(cls, app: QApplication) -> None: + """Возврат к светлой теме системы.""" + if cls._original_style: + app.setStyle(cls._original_style) + if cls._original_palette: + app.setPalette(cls._original_palette) + if cls._original_stylesheet is not None: + app.setStyleSheet(cls._original_stylesheet) + + +class WorkerThread(QThread): + """Фоновый поток для долгих операций""" + progress = pyqtSignal(int, str) + finished = pyqtSignal(object) + error = pyqtSignal(str) + + def __init__(self, func, *args, **kwargs): + super().__init__() + self.func = func + self.args = args + self.kwargs = kwargs + + def run(self): + try: + logger.info(f"Запуск фоновой задачи: {self.func.__name__}") + result = self.func(*self.args, **self.kwargs) + logger.info(f"Задача завершена: {self.func.__name__}") + self.finished.emit(result) + except Exception as e: + logger.error( + f"Ошибка в задаче {self.func.__name__}: {e}", + exc_info=True + ) + self.error.emit(str(e)) + + +class LoginDialog(QDialog): + """Диалог авторизации""" + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Авторизация МойСклад") + self.setModal(True) + self.setMinimumWidth(400) + + layout = QFormLayout() + + self.login_input = QLineEdit() + self.login_input.setPlaceholderText("Логин") + layout.addRow("Логин:", self.login_input) + + self.password_input = QLineEdit() + self.password_input.setPlaceholderText("Пароль") + self.password_input.setEchoMode(QLineEdit.EchoMode.Password) + layout.addRow("Пароль:", self.password_input) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | + QDialogButtonBox.StandardButton.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addRow(buttons) + + self.setLayout(layout) + + def get_credentials(self): + return self.login_input.text(), self.password_input.text() + + +class MainWindow(QMainWindow): + """Главное окно приложения""" + + def __init__(self): + super().__init__() + self.client: Optional[MoySkladClient] = None + self.validator = OrderValidator() + self.xml_generator = XML1CGenerator() + self.current_orders: List[Dict] = [] + self.selected_order_data: Optional[Dict] = None + self.worker_thread: Optional[WorkerThread] = None + self.current_page = 0 + self.page_size = 25 + self.total_orders = 0 + + self.init_ui() + self.check_credentials() + + def init_ui(self): + """Инициализация UI""" + self.setWindowTitle("МойСклад - Генератор документов") + self.setGeometry(100, 100, 1200, 800) + + # Установка иконки приложения + icon_path = os.path.join(os.path.dirname(__file__), 'app_icon.png') + if os.path.exists(icon_path): + self.setWindowIcon(QIcon(icon_path)) + + # Центральный виджет + central_widget = QWidget() + self.setCentralWidget(central_widget) + + # Главный layout + main_layout = QVBoxLayout() + central_widget.setLayout(main_layout) + + # Строка статуса + status_layout = QHBoxLayout() + self.status_label = QLabel("Не авторизован") + self.status_label.setStyleSheet("color: red; font-weight: bold;") + status_layout.addWidget(self.status_label) + status_layout.addStretch() + + self.login_btn = QPushButton("Войти") + self.login_btn.clicked.connect(self.show_login_dialog) + status_layout.addWidget(self.login_btn) + + self.logout_btn = QPushButton("Выйти") + self.logout_btn.clicked.connect(self.logout) + self.logout_btn.setEnabled(False) + status_layout.addWidget(self.logout_btn) + + main_layout.addLayout(status_layout) + + # Tabs + self.tabs = QTabWidget() + main_layout.addWidget(self.tabs) + + # Вкладка поиска + self.search_tab = self.create_search_tab() + self.tabs.addTab(self.search_tab, "🔍 Поиск") + + # Вкладка списка + self.list_tab = self.create_list_tab() + self.tabs.addTab(self.list_tab, "📋 Список отгрузок") + + # Вкладка массовой генерации + self.batch_tab = self.create_batch_tab() + self.tabs.addTab(self.batch_tab, "📦 Массовая генерация") + + # Progress bar + self.progress_bar = QProgressBar() + self.progress_bar.setVisible(False) + main_layout.addWidget(self.progress_bar) + + def create_search_tab(self) -> QWidget: + """Создание вкладки поиска""" + widget = QWidget() + layout = QVBoxLayout() + + # Поиск + search_group = QGroupBox("Поиск отгрузки") + search_layout = QHBoxLayout() + + self.search_input = QLineEdit() + self.search_input.setPlaceholderText( + "Введите номер отгрузки..." + ) + self.search_input.returnPressed.connect(self.search_orders) + search_layout.addWidget(self.search_input) + + self.search_btn = QPushButton("Найти") + self.search_btn.clicked.connect(self.search_orders) + search_layout.addWidget(self.search_btn) + + search_group.setLayout(search_layout) + layout.addWidget(search_group) + + # Результаты + results_group = QGroupBox("Результаты") + results_layout = QVBoxLayout() + + self.search_results_table = QTableWidget() + self.search_results_table.setColumnCount(5) + self.search_results_table.setHorizontalHeaderLabels([ + "№", "Дата", "Контрагент", "Сумма", "Действия" + ]) + self.search_results_table.horizontalHeader().setStretchLastSection( + True + ) + self.search_results_table.setSelectionBehavior( + QTableWidget.SelectionBehavior.SelectRows + ) + results_layout.addWidget(self.search_results_table) + + results_group.setLayout(results_layout) + layout.addWidget(results_group) + + widget.setLayout(layout) + return widget + + def create_list_tab(self) -> QWidget: + """Создание вкладки списка""" + widget = QWidget() + layout = QVBoxLayout() + + # Кнопки управления + controls_layout = QHBoxLayout() + + self.load_list_btn = QPushButton("Загрузить список") + self.load_list_btn.clicked.connect(self.load_orders_list) + controls_layout.addWidget(self.load_list_btn) + + controls_layout.addStretch() + + self.prev_page_btn = QPushButton("← Предыдущая") + self.prev_page_btn.clicked.connect(self.prev_page) + self.prev_page_btn.setEnabled(False) + controls_layout.addWidget(self.prev_page_btn) + + self.page_label = QLabel("Страница: 1") + controls_layout.addWidget(self.page_label) + + self.next_page_btn = QPushButton("Следующая →") + self.next_page_btn.clicked.connect(self.next_page) + self.next_page_btn.setEnabled(False) + controls_layout.addWidget(self.next_page_btn) + + layout.addLayout(controls_layout) + + # Таблица + self.orders_table = QTableWidget() + self.orders_table.setColumnCount(5) + self.orders_table.setHorizontalHeaderLabels([ + "№", "Дата", "Контрагент", "Сумма", "Действия" + ]) + self.orders_table.horizontalHeader().setStretchLastSection(True) + self.orders_table.setSelectionBehavior( + QTableWidget.SelectionBehavior.SelectRows + ) + layout.addWidget(self.orders_table) + + widget.setLayout(layout) + return widget + + def create_batch_tab(self) -> QWidget: + """Создание вкладки массовой генерации""" + widget = QWidget() + layout = QVBoxLayout() + + # Ввод номеров + input_group = QGroupBox("Номера отгрузок") + input_layout = QVBoxLayout() + + info_label = QLabel( + "Введите номера отгрузок через запятую (максимум 50):" + ) + input_layout.addWidget(info_label) + + self.batch_input = QTextEdit() + self.batch_input.setPlaceholderText( + "Например: 4926,4927,4928" + ) + self.batch_input.setMaximumHeight(100) + input_layout.addWidget(self.batch_input) + + input_group.setLayout(input_layout) + layout.addWidget(input_group) + + # Настройки + settings_group = QGroupBox("Настройки") + settings_layout = QFormLayout() + + self.batch_mode_combo = QComboBox() + self.batch_mode_combo.addItems([ + "Один файл (все документы)", + "Отдельные файлы (каждый документ)" + ]) + settings_layout.addRow("Режим:", self.batch_mode_combo) + + self.validate_checkbox = QCheckBox("Валидировать перед генерацией") + self.validate_checkbox.setChecked(True) + settings_layout.addRow("", self.validate_checkbox) + + settings_group.setLayout(settings_layout) + layout.addWidget(settings_group) + + # Кнопка генерации + self.batch_generate_btn = QPushButton("Сгенерировать") + self.batch_generate_btn.clicked.connect(self.batch_generate) + layout.addWidget(self.batch_generate_btn) + + # Результаты + results_group = QGroupBox("Результаты") + results_layout = QVBoxLayout() + + self.batch_results = QTextEdit() + self.batch_results.setReadOnly(True) + results_layout.addWidget(self.batch_results) + + results_group.setLayout(results_layout) + layout.addWidget(results_group) + + widget.setLayout(layout) + return widget + + def check_credentials(self): + """Проверка сохраненных учетных данных""" + try: + self.client = MoySkladClient( + settings.moysklad_login, + settings.moysklad_password, + settings.moysklad_api_url + ) + # Тестовый запрос + self.client.get_orders(limit=1) + self.update_status(True) + except Exception: + self.update_status(False) + + def show_login_dialog(self): + """Показ диалога авторизации""" + dialog = LoginDialog(self) + if dialog.exec() == QDialog.DialogCode.Accepted: + login, password = dialog.get_credentials() + self.login(login, password) + + def login(self, login: str, password: str): + """Авторизация""" + try: + self.client = MoySkladClient( + login, password, settings.moysklad_api_url + ) + # Тестовый запрос + self.client.get_orders(limit=1) + self.update_status(True) + QMessageBox.information( + self, "Успех", "Авторизация успешна!" + ) + except Exception as e: + QMessageBox.critical( + self, "Ошибка", f"Ошибка авторизации:\n{e}" + ) + self.update_status(False) + + def logout(self): + """Выход""" + self.client = None + self.update_status(False) + self.current_orders = [] + self.orders_table.setRowCount(0) + self.search_results_table.setRowCount(0) + + def update_status(self, logged_in: bool): + """Обновление статуса авторизации""" + if logged_in: + self.status_label.setText("✓ Авторизован") + self.status_label.setStyleSheet( + "color: green; font-weight: bold;" + ) + self.login_btn.setEnabled(False) + self.logout_btn.setEnabled(True) + self.tabs.setEnabled(True) + else: + self.status_label.setText("✗ Не авторизован") + self.status_label.setStyleSheet("color: red; font-weight: bold;") + self.login_btn.setEnabled(True) + self.logout_btn.setEnabled(False) + self.tabs.setEnabled(False) + + def search_orders(self): + """Поиск отгрузок""" + if not self.client: + QMessageBox.warning(self, "Ошибка", "Необходима авторизация") + return + + query = self.search_input.text().strip() + if not query: + QMessageBox.warning(self, "Ошибка", "Введите номер отгрузки") + return + + # Блокируем кнопку на время поиска + self.search_btn.setEnabled(False) + self.show_progress("Поиск...") + + def search(): + try: + return self.client.search_orders(query, limit=10) + except Exception as e: + raise Exception(f"Ошибка поиска: {str(e)}") + + self.run_in_thread(search, self.on_search_complete) + + def on_search_complete(self, result): + """Обработка результатов поиска""" + self.hide_progress() + self.search_btn.setEnabled(True) + + if not result or 'rows' not in result: + QMessageBox.warning( + self, "Ошибка", "Не удалось выполнить поиск" + ) + return + + orders = result.get('rows', []) + if not orders: + QMessageBox.information( + self, "Результаты", "Отгрузки не найдены" + ) + return + + self.display_orders_in_table(orders, self.search_results_table) + + def load_orders_list(self): + """Загрузка списка отгрузок""" + if not self.client: + QMessageBox.warning(self, "Ошибка", "Необходима авторизация") + return + + # Сброс на первую страницу + self.current_page = 0 + self.load_page() + + def load_page(self): + """Загрузка текущей страницы""" + if not self.client: + return + + # Блокируем кнопки на время загрузки + self.load_list_btn.setEnabled(False) + self.prev_page_btn.setEnabled(False) + self.next_page_btn.setEnabled(False) + + offset = self.current_page * self.page_size + self.show_progress( + f"Загрузка страницы {self.current_page + 1}..." + ) + + def load(): + try: + result = self.client.get_orders( + limit=self.page_size, + offset=offset + ) + # Получаем общее количество + total = result.get('meta', {}).get('size', 0) + return result, total + except Exception as e: + raise Exception(f"Ошибка загрузки списка: {str(e)}") + + self.run_in_thread(load, self.on_list_complete) + + def on_list_complete(self, result): + """Обработка списка отгрузок""" + self.hide_progress() + self.load_list_btn.setEnabled(True) + + if not result: + QMessageBox.warning( + self, "Ошибка", "Не удалось загрузить список отгрузок" + ) + return + + # result теперь кортеж (data, total) + data, total = result + + if not data or 'rows' not in data: + QMessageBox.warning( + self, "Ошибка", "Не удалось загрузить список отгрузок" + ) + return + + self.current_orders = data.get('rows', []) + self.total_orders = total + + # Обновляем кнопки пагинации + self.update_pagination_buttons() + + # Отображаем отгрузки + self.display_orders_in_table(self.current_orders, self.orders_table) + + def update_pagination_buttons(self): + """Обновление кнопок пагинации""" + total_pages = ( + (self.total_orders + self.page_size - 1) // self.page_size + ) + + # Обновляем текст страницы + self.page_label.setText( + f"Страница: {self.current_page + 1} из {total_pages} " + f"(всего: {self.total_orders})" + ) + + # Включаем/выключаем кнопки + self.prev_page_btn.setEnabled(self.current_page > 0) + self.next_page_btn.setEnabled( + (self.current_page + 1) * self.page_size < self.total_orders + ) + + def display_orders_in_table(self, orders: List[Dict], table: QTableWidget): + """Отображение отгрузок в таблице""" + table.setRowCount(len(orders)) + + for i, order in enumerate(orders): + # Номер + table.setItem(i, 0, QTableWidgetItem(order.get('name', 'N/A'))) + + # Дата + date_str = order.get('moment', '')[:10] + table.setItem(i, 1, QTableWidgetItem(date_str)) + + # Контрагент + agent = order.get('agent', {}) + table.setItem(i, 2, QTableWidgetItem(agent.get('name', 'N/A'))) + + # Сумма + sum_val = order.get('sum', 0) / 100 + table.setItem(i, 3, QTableWidgetItem(f"{sum_val:,.2f} ₽")) + + # Кнопки действий + actions_widget = QWidget() + actions_layout = QHBoxLayout() + actions_layout.setContentsMargins(2, 2, 2, 2) + + validate_btn = QPushButton("Валидировать") + validate_btn.clicked.connect( + lambda checked, o=order: self.validate_order(o) + ) + actions_layout.addWidget(validate_btn) + + generate_btn = QPushButton("Генерировать XML") + generate_btn.clicked.connect( + lambda checked, o=order: self.generate_xml_for_order(o) + ) + actions_layout.addWidget(generate_btn) + + actions_widget.setLayout(actions_layout) + table.setCellWidget(i, 4, actions_widget) + + def validate_order(self, order: Dict): + """Валидация отгрузки""" + if not self.client: + return + + order_id = order['id'] + self.show_progress("Валидация...") + + def validate(): + order_data = self.client.get_full_order_data(order_id) + results = self.validator.validate_order(order_data) + return order_data, results + + self.run_in_thread(validate, self.on_validate_complete) + + def on_validate_complete(self, result): + """Обработка результатов валидации""" + self.hide_progress() + order_data, validation_results = result + + level, summary = self.validator.get_validation_summary( + validation_results + ) + + if level == ValidationLevel.CRITICAL: + QMessageBox.critical(self, "Ошибки валидации", summary) + elif level == ValidationLevel.WARNING: + QMessageBox.warning(self, "Предупреждения", summary) + else: + QMessageBox.information(self, "Валидация", summary) + + def generate_xml_for_order(self, order: Dict): + """Генерация XML для отгрузки""" + if not self.client: + return + + order_id = order['id'] + self.show_progress("Генерация XML...") + + def generate(): + order_data = self.client.get_full_order_data(order_id) + xml_content = self.xml_generator.generate_realization(order_data) + return order_data, xml_content + + self.run_in_thread(generate, self.on_generate_complete) + + def on_generate_complete(self, result): + """Обработка сгенерированного XML""" + self.hide_progress() + order_data, xml_content = result + + order_name = order_data['order'].get('name', 'order') + filename, _ = QFileDialog.getSaveFileName( + self, + "Сохранить XML", + f"order_{order_name}.xml", + "XML Files (*.xml)" + ) + + if filename: + with open(filename, 'w', encoding='utf-8') as f: + f.write(xml_content) + QMessageBox.information( + self, "Успех", f"XML сохранен:\n{filename}" + ) + + def batch_generate(self): + """Массовая генерация""" + if not self.client: + QMessageBox.warning(self, "Ошибка", "Необходима авторизация") + return + + order_numbers = [ + num.strip() + for num in self.batch_input.toPlainText().split(',') + if num.strip() + ] + + if not order_numbers: + QMessageBox.warning( + self, "Ошибка", "Введите номера отгрузок" + ) + return + + if len(order_numbers) > 50: + QMessageBox.warning( + self, "Ошибка", "Максимум 50 отгрузок за раз" + ) + return + + batch_mode = self.batch_mode_combo.currentIndex() == 0 + validate = self.validate_checkbox.isChecked() + + self.show_progress("Обработка...") + self.batch_results.clear() + + def process(): + return self.process_batch(order_numbers, batch_mode, validate) + + self.run_in_thread(process, self.on_batch_complete) + + def process_batch( + self, + order_numbers: List[str], + batch_mode: bool, + validate: bool + ) -> Dict: + """Обработка пакета отгрузок""" + orders_data = [] + not_found = [] + errors = [] + validation_errors = {} + + for order_num in order_numbers: + try: + # Поиск + search_result = self.client.search_orders(order_num, limit=1) + rows = search_result.get('rows', []) + + if not rows: + not_found.append(order_num) + continue + + order_id = rows[0]['id'] + order_data = self.client.get_full_order_data(order_id) + + # Валидация + if validate: + results = self.validator.validate_order(order_data) + critical = [ + r for r in results + if r.level == ValidationLevel.CRITICAL + ] + + if critical: + validation_errors[order_num] = [ + f"{r.field}: {r.message}" for r in critical + ] + continue + + orders_data.append(order_data) + + except Exception as e: + errors.append(f"{order_num}: {str(e)[:50]}") + + return { + 'orders_data': orders_data, + 'not_found': not_found, + 'errors': errors, + 'validation_errors': validation_errors, + 'batch_mode': batch_mode + } + + def on_batch_complete(self, result): + """Обработка результатов пакетной генерации""" + self.hide_progress() + + orders_data = result['orders_data'] + not_found = result['not_found'] + errors = result['errors'] + validation_errors = result['validation_errors'] + batch_mode = result['batch_mode'] + + # Формируем отчет + report = [] + report.append(f"Обработано: {len(orders_data)} отгрузок") + + if validation_errors: + report.append(f"\n❌ Ошибки валидации: {len(validation_errors)}") + for order_num, errs in validation_errors.items(): + report.append(f"\n {order_num}:") + for err in errs: + report.append(f" • {err}") + + if not_found: + report.append(f"\n⚠️ Не найдено: {', '.join(not_found)}") + + if errors: + report.append("\n⚠️ Ошибки обработки:") + for err in errors: + report.append(f" • {err}") + + self.batch_results.setText('\n'.join(report)) + + # Генерация XML + if orders_data: + if batch_mode: + self.generate_batch_xml(orders_data) + else: + self.generate_separate_xmls(orders_data) + else: + QMessageBox.warning( + self, + "Предупреждение", + "Нет отгрузок для генерации" + ) + + def generate_batch_xml(self, orders_data: List[Dict]): + """Генерация одного XML со всеми документами""" + filename, _ = QFileDialog.getSaveFileName( + self, + "Сохранить пакетный XML", + f"batch_{len(orders_data)}_orders.xml", + "XML Files (*.xml)" + ) + + if filename: + self.xml_generator.generate_batch_realization( + orders_data, filename + ) + QMessageBox.information( + self, "Успех", f"Пакетный XML сохранен:\n{filename}" + ) + + def generate_separate_xmls(self, orders_data: List[Dict]): + """Генерация отдельных XML файлов""" + directory = QFileDialog.getExistingDirectory( + self, "Выберите папку для сохранения" + ) + + if directory: + for order_data in orders_data: + order_name = order_data['order'].get('name', 'order') + filename = os.path.join( + directory, f"order_{order_name}.xml" + ) + xml_content = self.xml_generator.generate_realization( + order_data + ) + with open(filename, 'w', encoding='utf-8') as f: + f.write(xml_content) + + QMessageBox.information( + self, + "Успех", + f"Сохранено {len(orders_data)} файлов в:\n{directory}" + ) + + def run_in_thread(self, func, callback): + """Запуск функции в отдельном потоке""" + # Останавливаем предыдущий поток если есть + if self.worker_thread and self.worker_thread.isRunning(): + self.worker_thread.wait() + + self.worker_thread = WorkerThread(func) + self.worker_thread.finished.connect(callback) + self.worker_thread.error.connect(self.on_thread_error) + self.worker_thread.start() + + def on_thread_error(self, error_msg: str): + """Обработка ошибки в потоке""" + self.hide_progress() + # Разблокируем кнопки + self.search_btn.setEnabled(True) + self.load_list_btn.setEnabled(True) + + QMessageBox.critical(self, "Ошибка", error_msg) + + def show_progress(self, message: str): + """Показ прогресса""" + self.progress_bar.setVisible(True) + self.progress_bar.setRange(0, 0) # Неопределенный прогресс + self.statusBar().showMessage(message) + + def hide_progress(self): + """Скрытие прогресса""" + self.progress_bar.setVisible(False) + self.statusBar().clearMessage() + + def prev_page(self): + """Предыдущая страница""" + if self.current_page > 0: + self.current_page -= 1 + self.load_page() + + def next_page(self): + """Следующая страница""" + if (self.current_page + 1) * self.page_size < self.total_orders: + self.current_page += 1 + self.load_page() + + +def main(): + """Запуск приложения""" + app = QApplication(sys.argv) + app.setApplicationName("МойСклад - Генератор документов") + app.setOrganizationName("BUH_bot") + + # Установка иконки приложения для всех окон + icon_path = os.path.join(os.path.dirname(__file__), 'app_icon.png') + if os.path.exists(icon_path): + app.setWindowIcon(QIcon(icon_path)) + + ThemeManager.apply(app) + + window = MainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/gui_app.spec b/gui_app.spec new file mode 100644 index 0000000..5d383de --- /dev/null +++ b/gui_app.spec @@ -0,0 +1,75 @@ +# -*- mode: python ; coding: utf-8 -*- +""" +PyInstaller spec file для сборки GUI приложения +Создает standalone exe-файл со всеми зависимостями +""" +import os + +block_cipher = None + +# Дополнительные данные для включения +added_files = [ + ('modules/*.py', 'modules'), + ('.env.example', '.'), + ('app_icon.png', '.'), +] + +# Скрытые импорты +hiddenimports = [ + 'PyQt6.QtCore', + 'PyQt6.QtGui', + 'PyQt6.QtWidgets', + 'lxml.etree', + 'lxml._elementpath', + 'requests', + 'cryptography', + 'cryptography.fernet', +] + +a = Analysis( + ['gui_app.py'], + pathex=[], + binaries=[], + datas=added_files, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[ + 'telegram', + 'telegram.ext', + 'matplotlib', + 'numpy', + 'pandas', + 'tkinter', + ], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='MoySkladGenerator', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, # Без консоли + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon='app_icon.ico' if os.path.exists('app_icon.ico') else None, +) diff --git a/installer.iss b/installer.iss new file mode 100644 index 0000000..ff5fd4e --- /dev/null +++ b/installer.iss @@ -0,0 +1,83 @@ +; Inno Setup Script для установки MoySklad Generator +; Создает автономный установщик со всеми зависимостями + +#define MyAppName "МойСклад - Генератор документов" +#define MyAppVersion "1.0.0" +#define MyAppPublisher "BUH_bot" +#define MyAppExeName "MoySkladGenerator.exe" + +[Setup] +AppId={{B7A8D2C1-3F4E-4A5B-9C6D-1E2F3A4B5C6D} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppName} +AllowNoIcons=yes +OutputDir=installer_output +OutputBaseFilename=MoySklad_Generator_Setup +SetupIconFile=app_icon.ico +UninstallDisplayIcon={app}\{#MyAppExeName} +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +DisableWelcomePage=no +LicenseFile=LICENSE.txt +InfoBeforeFile=INSTALL_INFO.txt +PrivilegesRequired=admin + +[Languages] +Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl" +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" +Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +; Основной исполняемый файл +Source: "dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion + +; Дополнительные файлы +Source: "README.md"; DestDir: "{app}"; Flags: ignoreversion +Source: ".env.example"; DestDir: "{app}"; Flags: ignoreversion +Source: "CHANGELOG.md"; DestDir: "{app}"; Flags: ignoreversion + +; Документация +Source: "docs\*"; DestDir: "{app}\docs"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{group}\Документация"; Filename: "{app}\docs" +Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon +Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: quicklaunchicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + +[Code] +procedure InitializeWizard; +var + WelcomeLabel: TNewStaticText; +begin + WelcomeLabel := TNewStaticText.Create(WizardForm); + WelcomeLabel.Parent := WizardForm.WelcomePage; + WelcomeLabel.Caption := + 'Это приложение для генерации XML документов из МойСклад.' + #13#10 + + 'Работает полностью автономно, без подключения к интернету.' + #13#10#13#10 + + 'Возможности:' + #13#10 + + ' • Поиск и просмотр отгрузок' + #13#10 + + ' • Валидация данных' + #13#10 + + ' • Генерация XML для 1С' + #13#10 + + ' • Массовая генерация документов'; + WelcomeLabel.Top := WizardForm.WelcomeLabel2.Top + WizardForm.WelcomeLabel2.Height + 20; + WelcomeLabel.Width := WizardForm.WelcomeLabel2.Width; + WelcomeLabel.AutoSize := True; +end; + +function InitializeSetup(): Boolean; +begin + Result := True; + // Здесь можно добавить проверки системных требований +end; diff --git a/modules/validator.py b/modules/validator.py index 841026f..dffb625 100644 --- a/modules/validator.py +++ b/modules/validator.py @@ -387,7 +387,7 @@ class OrderValidator: level=ValidationLevel.CRITICAL, field=f"position[{index}].price", message=f"Позиция {pos_num} ({name or 'без названия'}): " - f"цена равна нулю (недопустимо для УПД/ТОРГ-12)" + f"цена равна нулю" )) # Проверка суммы строки (если есть цена и количество) diff --git a/modules/xml_generator.py b/modules/xml_generator.py index f91cc9b..2a5ba4d 100644 --- a/modules/xml_generator.py +++ b/modules/xml_generator.py @@ -4,12 +4,64 @@ """ from lxml import etree from datetime import datetime -from typing import Dict, Optional +from typing import Dict, List, Optional, Tuple import logging +from config import settings + logger = logging.getLogger(__name__) +# Константы для российских кодов и идентификаторов +RUB_ALPHA_CODE = "RUB" +RUB_NUMERIC_CODE = "643" +RUB_FULL_NAME = "Российский рубль" +RUB_SHORT_NAME = "руб." +RUB_CURRENCY_GUID = "72357d18-63a8-11e8-80dd-0050569f5448" + +RUSSIA_COUNTRY_GUID = "67700c1b-63a8-11e8-80dd-0050569f5448" +RUSSIA_OKSM_CODE = "643" +RUSSIA_COUNTRY_NAME = "РОССИЯ" + +DEFAULT_UOM_GUID = "cdf130e2-63a8-11e8-80dd-0050569f5448" + +ACCOUNTING_ACCOUNT_TAGS = ( + ("СчетУчета", "43"), + ("СчетДоходов", "90.01.1"), + ("СчетРасходов", "90.02.1"), + ("СчетУчетаНДСПоРеализации", "90.03"), +) + + +def get_currency_codes(currency: Optional[Dict]) -> Tuple[str, str]: + """Возвращает буквенный и цифровой коды валюты. + + При отсутствии данных возвращаются значения по умолчанию. + """ + if not currency: + return RUB_ALPHA_CODE, RUB_NUMERIC_CODE + + iso_value = currency.get("isoCode") + if isinstance(iso_value, str): + alpha_code = iso_value.strip().upper() + else: + alpha_code = "" + + numeric_value = currency.get("code") + if isinstance(numeric_value, str): + numeric_code = numeric_value.strip() + else: + numeric_code = "" + + if not alpha_code and numeric_code == RUB_NUMERIC_CODE: + alpha_code = RUB_ALPHA_CODE + + if not numeric_code and alpha_code == RUB_ALPHA_CODE: + numeric_code = RUB_NUMERIC_CODE + + return alpha_code or RUB_ALPHA_CODE, numeric_code or RUB_NUMERIC_CODE + + class XMLGenerator: """Генератор XML документов в формате ВерсФорм 5.03""" @@ -47,10 +99,62 @@ class XMLGenerator: Сумма в рублях с 2 знаками после запятой """ return f"{amount / 100:.2f}" + + @staticmethod + def _get_preferred_address(data: Dict, fields: List[str]) -> str: + """Возвращает первый непустой адрес по списку полей.""" + for field in fields: + value = data.get(field) + if value: + return value + return "" + + def _add_bank_details( + self, + parent: etree.Element, + accounts: Optional[List[Dict]] + ) -> None: + """Добавляет банковские реквизиты (первый счет с БИК).""" + if not accounts: + return + + for account in accounts: + if not account.get("bic"): + continue + + bank_info = etree.SubElement( + parent, + "БанкРекв", + НомерСчета=account.get("accountNumber", "") + ) + etree.SubElement( + bank_info, + "СвБанк", + НаимБанк=account.get("bankName", ""), + БИК=account.get("bic", ""), + КорСчет=account.get("correspondentAccount", "") + ) + break + + @staticmethod + def _add_contact_details( + parent: etree.Element, + phone: Optional[str] = None, + email: Optional[str] = None + ) -> None: + """Добавляет контактную информацию, если она есть.""" + if not phone and not email: + return + + contact = etree.SubElement(parent, "Контакт") + if phone: + etree.SubElement(contact, "Тлф").text = phone + if email: + etree.SubElement(contact, "ЭлПочта").text = email def _add_seller_info( - self, - parent: etree.Element, + self, + parent: etree.Element, organization: Dict, accounts: list = None ) -> None: @@ -100,7 +204,7 @@ class XMLGenerator: else: # Юридическое лицо kpp = organization.get("kpp", "") - sv_ul = etree.SubElement( + _ = etree.SubElement( seller_info, "СвЮЛУч", НаимОрг=organization.get("name", ""), ИННЮЛ=inn, @@ -117,31 +221,14 @@ class XMLGenerator: ) # Банковские реквизиты - 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 - + self._add_bank_details(seller, accounts) + # Контактная информация - 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 + self._add_contact_details( + seller, + phone=organization.get("phone", ""), + email=organization.get("email", "") + ) def _add_shipper_info( self, @@ -203,10 +290,9 @@ class XMLGenerator: etree.SubElement(shipper_info, "НаимОрг", Наим=name) # Адрес (приоритет: deliveryAddress -> actualAddress -> legalAddress) - address = ( - counterparty.get("deliveryAddress") or - counterparty.get("actualAddress") or - counterparty.get("legalAddress", "") + address = self._get_preferred_address( + counterparty, + ["deliveryAddress", "actualAddress", "legalAddress"] ) if address: addr_element = etree.SubElement(shipper, "Адрес") @@ -216,26 +302,13 @@ class XMLGenerator: ) # Банковские реквизиты - 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 + self._add_bank_details(shipper, accounts) # Контактная информация - phone = counterparty.get("phone", "") - if phone: - contact = etree.SubElement(shipper, "Контакт") - etree.SubElement(contact, "Тлф").text = phone + self._add_contact_details( + shipper, + phone=counterparty.get("phone", "") + ) def _add_buyer_info( self, @@ -296,8 +369,11 @@ class XMLGenerator: name = counterparty.get("legalTitle", "") etree.SubElement(buyer_info, "НаимОрг", Наим=name) - # Адрес (для покупателя используем юридический адрес) - address = counterparty.get("legalAddress") or counterparty.get("actualAddress", "") + # Адрес (для покупателя: сначала юридический, затем фактический) + address = self._get_preferred_address( + counterparty, + ["legalAddress", "actualAddress"] + ) if address: addr_element = etree.SubElement(buyer, "Адрес") etree.SubElement( @@ -306,35 +382,18 @@ class XMLGenerator: ) # Банковские реквизиты - 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 + self._add_bank_details(buyer, accounts) # Контактная информация - 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 + self._add_contact_details( + buyer, + phone=counterparty.get("phone", ""), + email=counterparty.get("email", "") + ) def _add_positions_table( - self, - parent: etree.Element, + self, + parent: etree.Element, positions: list ) -> Dict[str, float]: """ @@ -432,8 +491,8 @@ class XMLGenerator: } def generate_upd( - self, - order_data: Dict, + self, + order_data: Dict, output_path: Optional[str] = None ) -> str: """ @@ -501,14 +560,16 @@ class XMLGenerator: rate = order.get("rate", {}) if rate and "currency" in rate: currency = rate["currency"] + _, currency_numeric_code = get_currency_codes(currency) etree.SubElement( - invoice_info, "ДенИзм", - КодОКВ=currency.get("code", "643"), - НаимОКВ=currency.get("fullName", "Российский рубль") + invoice_info, + "ДенИзм", + КодОКВ=currency_numeric_code, + НаимОКВ=currency.get("fullName", RUB_FULL_NAME) ) # Таблица товаров - totals = self._add_positions_table(doc, positions) + _ = self._add_positions_table(doc, positions) # Сведения о передаче товара sv_prod_per = etree.SubElement(doc, "СвПродПер") @@ -607,7 +668,7 @@ class XMLGenerator: self._add_buyer_info(doc, counterparty) # Таблица товаров - totals = self._add_positions_table(doc, positions) + _ = self._add_positions_table(doc, positions) # Конвертация в строку xml_string = etree.tostring( @@ -778,16 +839,25 @@ class XML1CGenerator: rate = order.get("rate", {}) currency = rate.get("currency", {}) if rate else {} currency_elem = etree.SubElement(doc, "Валюта") - currency_href = currency.get("meta", {}).get("href", "") if currency.get("meta") else "" - currency_id = currency_href.split("/")[-1].split("?")[0] if currency_href else "" - # Если нет UUID, используем стандартный UUID для рубля + + currency_href = "" + if currency.get("meta"): + currency_href = currency.get("meta", {}).get("href", "") + + currency_id = "" + if currency_href: + currency_id = currency_href.split("/")[-1].split("?")[0] if not currency_id: - currency_id = "72357d18-63a8-11e8-80dd-0050569f5448" # Стандартный GUID для RUB + currency_id = RUB_CURRENCY_GUID etree.SubElement(currency_elem, "Ссылка").text = currency_id - + + alpha_code, numeric_code = get_currency_codes(currency) currency_data = etree.SubElement(currency_elem, "ДанныеКлассификатора") - etree.SubElement(currency_data, "Код").text = currency.get("isoCode", "643") - etree.SubElement(currency_data, "Наименование").text = currency.get("name", "руб.") + etree.SubElement(currency_data, "Код").text = numeric_code + etree.SubElement( + currency_data, + "Наименование" + ).text = currency.get("name", RUB_SHORT_NAME) # Сумма (в рублях, без копеек) order_sum = order.get("sum", 0) @@ -886,11 +956,16 @@ class XML1CGenerator: # Страна регистрации (ПЕРЕД ИндивидуальныйПредприниматель!) country_elem = etree.SubElement(cp_elem, "СтранаРегистрации") - # UUID России из классификатора 1С (стандартный для всех баз) - etree.SubElement(country_elem, "Ссылка").text = "67700c1b-63a8-11e8-80dd-0050569f5448" - country_data = etree.SubElement(country_elem, "ДанныеКлассификатора") - etree.SubElement(country_data, "Код").text = "643" - etree.SubElement(country_data, "Наименование").text = "РОССИЯ" + etree.SubElement(country_elem, "Ссылка").text = RUSSIA_COUNTRY_GUID + country_data = etree.SubElement( + country_elem, + "ДанныеКлассификатора" + ) + etree.SubElement(country_data, "Код").text = RUSSIA_OKSM_CODE + etree.SubElement( + country_data, + "Наименование" + ).text = RUSSIA_COUNTRY_NAME # ИндивидуальныйПредприниматель только для ИП (после СтранаРегистрации) if cp_type == "entrepreneur": @@ -908,15 +983,19 @@ class XML1CGenerator: # - валюта → 62.21/62.22 (валютные расчёты) # - у.е. → 62.31/62.32 (расчёты в у.е.) import uuid - currency_code = currency.get("isoCode", "643") + currency_code_for_seed = alpha_code or numeric_code is_ue = "false" # В текущей реализации всегда false - seed = f"contract-{cp_id}-{org_id}-CUR-{currency_code}-UE-{is_ue}" + seed = ( + f"contract-{cp_id}-{org_id}-CUR-" + f"{currency_code_for_seed}-UE-{is_ue}" + ) contract_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, seed)) logger.info( f"Создан договор с учётом валюты: " f"контрагент={cp_id}, организация={org_id}, " - f"валюта={currency_code}, у.е.={is_ue}, UUID={contract_uuid}" + f"валюта={alpha_code}/{numeric_code}, " + f"у.е.={is_ue}, UUID={contract_uuid}" ) etree.SubElement(contract_elem, "Ссылка").text = contract_uuid etree.SubElement(contract_elem, "ВидДоговора").text = "СПокупателем" @@ -954,11 +1033,16 @@ class XML1CGenerator: # Страна регистрации (ПЕРЕД ИндивидуальныйПредприниматель!) cp_country = etree.SubElement(contract_cp, "СтранаРегистрации") - # UUID России из классификатора 1С (стандартный для всех баз) - etree.SubElement(cp_country, "Ссылка").text = "67700c1b-63a8-11e8-80dd-0050569f5448" - cp_country_data = etree.SubElement(cp_country, "ДанныеКлассификатора") - etree.SubElement(cp_country_data, "Код").text = "643" - etree.SubElement(cp_country_data, "Наименование").text = "РОССИЯ" + etree.SubElement(cp_country, "Ссылка").text = RUSSIA_COUNTRY_GUID + cp_country_data = etree.SubElement( + cp_country, + "ДанныеКлассификатора" + ) + etree.SubElement(cp_country_data, "Код").text = RUSSIA_OKSM_CODE + etree.SubElement( + cp_country_data, + "Наименование" + ).text = RUSSIA_COUNTRY_NAME # ИндивидуальныйПредприниматель только для ИП (после СтранаРегистрации) if cp_type == "entrepreneur": @@ -967,19 +1051,34 @@ class XML1CGenerator: # Валюта взаиморасчетов в договоре contract_currency = etree.SubElement(contract_elem, "ВалютаВзаиморасчетов") etree.SubElement(contract_currency, "Ссылка").text = currency_id - contract_curr_data = etree.SubElement(contract_currency, "ДанныеКлассификатора") - etree.SubElement(contract_curr_data, "Код").text = currency.get("isoCode", "643") - etree.SubElement(contract_curr_data, "Наименование").text = currency.get("name", "руб.") + contract_curr_data = etree.SubElement( + contract_currency, + "ДанныеКлассификатора" + ) + etree.SubElement(contract_curr_data, "Код").text = numeric_code + etree.SubElement( + contract_curr_data, + "Наименование" + ).text = currency.get("name", RUB_SHORT_NAME) etree.SubElement(contract_elem, "РасчетыВУсловныхЕдиницах").text = "false" etree.SubElement(contract_elem, "Наименование").text = "Основной договор" # Валюта взаиморасчетов - settlement_currency = etree.SubElement(settlement_elem, "ВалютаВзаиморасчетов") + settlement_currency = etree.SubElement( + settlement_elem, + "ВалютаВзаиморасчетов" + ) etree.SubElement(settlement_currency, "Ссылка").text = currency_id - settlement_curr_data = etree.SubElement(settlement_currency, "ДанныеКлассификатора") - etree.SubElement(settlement_curr_data, "Код").text = currency.get("isoCode", "643") - etree.SubElement(settlement_curr_data, "Наименование").text = currency.get("name", "руб.") + settlement_curr_data = etree.SubElement( + settlement_currency, + "ДанныеКлассификатора" + ) + etree.SubElement(settlement_curr_data, "Код").text = numeric_code + etree.SubElement( + settlement_curr_data, + "Наименование" + ).text = currency.get("name", RUB_SHORT_NAME) etree.SubElement(settlement_elem, "КурсВзаиморасчетов").text = "1" etree.SubElement(settlement_elem, "КратностьВзаиморасчетов").text = "1" @@ -1045,10 +1144,16 @@ class XML1CGenerator: vat_country = etree.SubElement(vat_elem, "Страна") # Используем стандартный UUID России из классификатора 1С - etree.SubElement(vat_country, "Ссылка").text = "67700c1b-63a8-11e8-80dd-0050569f5448" - vat_country_data = etree.SubElement(vat_country, "ДанныеКлассификатора") - etree.SubElement(vat_country_data, "Код").text = "643" - etree.SubElement(vat_country_data, "Наименование").text = "РОССИЯ" + etree.SubElement(vat_country, "Ссылка").text = RUSSIA_COUNTRY_GUID + vat_country_data = etree.SubElement( + vat_country, + "ДанныеКлассификатора" + ) + etree.SubElement(vat_country_data, "Код").text = RUSSIA_OKSM_CODE + etree.SubElement( + vat_country_data, + "Наименование" + ).text = RUSSIA_COUNTRY_NAME # СуммаНДС (извлекаем из суммы) vat_amount = total / (1 + vat_rate / 100) * (vat_rate / 100) if vat_rate else 0 @@ -1058,11 +1163,9 @@ class XML1CGenerator: # Если импорт падает - закомментируйте эти строки # Дополнительные поля (опционально, зависит от конфигурации 1С) etree.SubElement(row_elem, "ТипЗапасов").text = "СобственныеТовары" - # TODO: Сделать опциональными через config - # etree.SubElement(row_elem, "СчетУчета").text = "43" - # etree.SubElement(row_elem, "СчетДоходов").text = "90.01.1" - # etree.SubElement(row_elem, "СчетРасходов").text = "90.02.1" - # etree.SubElement(row_elem, "СчетУчетаНДСПоРеализации").text = "90.03" + if settings.include_accounting_accounts: + for tag, value in ACCOUNTING_ACCOUNT_TAGS: + etree.SubElement(row_elem, tag).text = value # Уникальный идентификатор строки (uuid уже импортирован выше) etree.SubElement(row_elem, "ИдентификаторСтроки").text = str(uuid.uuid4()) @@ -1090,7 +1193,9 @@ class XML1CGenerator: # Если валюта RUB и не у.е., 1С должна использовать счета 62.01/62.02 # Если валюта иностранная → 62.21/62.22 # Если у.е. → 62.31/62.32 - if currency_code == "643" or currency_code == "RUB": + currency_label = f"{alpha_code}/{numeric_code}" + + if numeric_code == RUB_NUMERIC_CODE: logger.info( "✓ Документ в рублях (RUB, код 643), не в у.е. " "→ ожидаются счета 62.01 (задолженность) / 62.02 (авансы). " @@ -1102,10 +1207,10 @@ class XML1CGenerator: ) else: logger.info( - f"⚠ Документ в иностранной валюте ({currency_code}) " + f"⚠ Документ в иностранной валюте ({currency_label}) " "→ ожидаются счета 62.21/62.22. " "Убедитесь, что договор создан с валютой взаиморасчётов " - f"= {currency_code}." + f"= {currency_label}." ) # Сохранение в файл @@ -1289,18 +1394,17 @@ class XML1CGenerator: if currency_href else "" ) if not currency_id: - currency_id = "72357d18-63a8-11e8-80dd-0050569f5448" + currency_id = RUB_CURRENCY_GUID etree.SubElement(currency_elem, "Ссылка").text = currency_id - + + alpha_code, numeric_code = get_currency_codes(currency) currency_data = etree.SubElement( currency_elem, "ДанныеКлассификатора" ) - etree.SubElement( - currency_data, "Код" - ).text = currency.get("isoCode", "643") + etree.SubElement(currency_data, "Код").text = numeric_code etree.SubElement( currency_data, "Наименование" - ).text = currency.get("name", "руб.") + ).text = currency.get("name", RUB_SHORT_NAME) # Сумма order_sum = order.get("sum", 0) @@ -1438,14 +1542,16 @@ class XML1CGenerator: ).text = "ЮридическоеЛицо" country_elem = etree.SubElement(cp_elem, "СтранаРегистрации") - etree.SubElement( - country_elem, "Ссылка" - ).text = "67700c1b-63a8-11e8-80dd-0050569f5448" + etree.SubElement(country_elem, "Ссылка").text = RUSSIA_COUNTRY_GUID country_data = etree.SubElement( - country_elem, "ДанныеКлассификатора" + country_elem, + "ДанныеКлассификатора" ) - etree.SubElement(country_data, "Код").text = "643" - etree.SubElement(country_data, "Наименование").text = "РОССИЯ" + etree.SubElement(country_data, "Код").text = RUSSIA_OKSM_CODE + etree.SubElement( + country_data, + "Наименование" + ).text = RUSSIA_COUNTRY_NAME if cp_type == "entrepreneur": etree.SubElement( @@ -1458,9 +1564,12 @@ class XML1CGenerator: # Договор contract_elem = etree.SubElement(settlement_elem, "Договор") import uuid - currency_code = currency.get("isoCode", "643") + currency_code_for_seed = alpha_code or numeric_code is_ue = "false" - seed = f"contract-{cp_id}-{org_id}-CUR-{currency_code}-UE-{is_ue}" + seed = ( + f"contract-{cp_id}-{org_id}-CUR-" + f"{currency_code_for_seed}-UE-{is_ue}" + ) contract_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, seed)) etree.SubElement(contract_elem, "Ссылка").text = contract_uuid @@ -1525,14 +1634,16 @@ class XML1CGenerator: ).text = "ЮридическоеЛицо" cp_country = etree.SubElement(contract_cp, "СтранаРегистрации") - etree.SubElement( - cp_country, "Ссылка" - ).text = "67700c1b-63a8-11e8-80dd-0050569f5448" + etree.SubElement(cp_country, "Ссылка").text = RUSSIA_COUNTRY_GUID cp_country_data = etree.SubElement( - cp_country, "ДанныеКлассификатора" + cp_country, + "ДанныеКлассификатора" ) - etree.SubElement(cp_country_data, "Код").text = "643" - etree.SubElement(cp_country_data, "Наименование").text = "РОССИЯ" + etree.SubElement(cp_country_data, "Код").text = RUSSIA_OKSM_CODE + etree.SubElement( + cp_country_data, + "Наименование" + ).text = RUSSIA_COUNTRY_NAME if cp_type == "entrepreneur": etree.SubElement( @@ -1547,12 +1658,11 @@ class XML1CGenerator: contract_curr_data = etree.SubElement( contract_currency, "ДанныеКлассификатора" ) + etree.SubElement(contract_curr_data, "Код").text = numeric_code etree.SubElement( - contract_curr_data, "Код" - ).text = currency.get("isoCode", "643") - etree.SubElement( - contract_curr_data, "Наименование" - ).text = currency.get("name", "руб.") + contract_curr_data, + "Наименование" + ).text = currency.get("name", RUB_SHORT_NAME) etree.SubElement( contract_elem, "РасчетыВУсловныхЕдиницах" @@ -1569,12 +1679,11 @@ class XML1CGenerator: settlement_curr_data = etree.SubElement( settlement_currency, "ДанныеКлассификатора" ) + etree.SubElement(settlement_curr_data, "Код").text = numeric_code etree.SubElement( - settlement_curr_data, "Код" - ).text = currency.get("isoCode", "643") - etree.SubElement( - settlement_curr_data, "Наименование" - ).text = currency.get("name", "руб.") + settlement_curr_data, + "Наименование" + ).text = currency.get("name", RUB_SHORT_NAME) etree.SubElement( settlement_elem, "КурсВзаиморасчетов" @@ -1636,7 +1745,7 @@ class XML1CGenerator: if uom_href else "" ) if not uom_id: - uom_id = "cdf130e2-63a8-11e8-80dd-0050569f5448" + uom_id = DEFAULT_UOM_GUID etree.SubElement(uom_elem, "Ссылка").text = uom_id uom_data = etree.SubElement(uom_elem, "ДанныеКлассификатора") @@ -1664,12 +1773,15 @@ class XML1CGenerator: vat_country = etree.SubElement(vat_elem, "Страна") etree.SubElement( vat_country, "Ссылка" - ).text = "67700c1b-63a8-11e8-80dd-0050569f5448" + ).text = RUSSIA_COUNTRY_GUID vat_country_data = etree.SubElement( vat_country, "ДанныеКлассификатора" ) - etree.SubElement(vat_country_data, "Код").text = "643" - etree.SubElement(vat_country_data, "Наименование").text = "РОССИЯ" + etree.SubElement(vat_country_data, "Код").text = RUSSIA_OKSM_CODE + etree.SubElement( + vat_country_data, + "Наименование" + ).text = RUSSIA_COUNTRY_NAME vat_amount = ( total / (1 + vat_rate / 100) * (vat_rate / 100) @@ -1682,6 +1794,9 @@ class XML1CGenerator: etree.SubElement( row_elem, "ТипЗапасов" ).text = "СобственныеТовары" + if settings.include_accounting_accounts: + for tag, value in ACCOUNTING_ACCOUNT_TAGS: + etree.SubElement(row_elem, tag).text = value etree.SubElement( row_elem, "ИдентификаторСтроки" ).text = str(uuid.uuid4()) diff --git a/modules/xml_generator_refactored2.py b/modules/xml_generator_refactored2.py new file mode 100644 index 0000000..1f4c85f --- /dev/null +++ b/modules/xml_generator_refactored2.py @@ -0,0 +1,446 @@ +""" +Генератор XML документов в формате 1С EnterpriseData 1.20 +Рефакторенная версия с устранением дублирования кода +""" +from lxml import etree +from datetime import datetime +from typing import Dict, List, Optional +import logging + +logger = logging.getLogger(__name__) + + +class XML1CGenerator: + """Генератор XML документов для 1С в формате EnterpriseData 1.20""" + + VERSION = "1.20" + NAMESPACE = "http://v8.1c.ru/8.1/data/enterprise" + NS_PREFIX = "v8" + + def __init__(self): + self.encoding = "UTF-8" + self.nsmap = {self.NS_PREFIX: self.NAMESPACE} + + # ========== Утилиты форматирования ========== + + @staticmethod + def _format_date(date_str: str) -> str: + """Форматирование даты в формат ГГГГ-ММ-ДДTЧЧ:ММ:СС""" + try: + dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) + return dt.strftime("%Y-%m-%dT%H:%M:%S") + except Exception as e: + logger.error(f"Ошибка форматирования даты: {e}") + return datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + + @staticmethod + def _format_money(amount: int) -> str: + """Форматирование денежной суммы (копейки -> рубли)""" + return f"{amount / 100:.2f}" + + @staticmethod + def _safe_get(data: Dict, key: str, default: str = "") -> str: + """Безопасное извлечение значения из словаря""" + return str(data.get(key, default) or default) + + # ========== Создание XML элементов ========== + + def _create_element( + self, + tag: str, + text: Optional[str] = None, + **attributes + ) -> etree.Element: + """Создание XML элемента с атрибутами""" + elem = etree.Element(f"{{{self.NAMESPACE}}}{tag}") + if text: + elem.text = text + for key, value in attributes.items(): + if value is not None: + elem.set(key, str(value)) + return elem + + def _add_property( + self, + parent: etree.Element, + name: str, + value: str, + value_type: str = "string" + ) -> None: + """Добавление свойства объекта 1С""" + prop = etree.SubElement(parent, f"{{{self.NAMESPACE}}}Property") + etree.SubElement(prop, f"{{{self.NAMESPACE}}}Name").text = name + + value_elem = etree.SubElement( + prop, + f"{{{self.NAMESPACE}}}Value", + ) + if value_type == "string": + value_elem.set( + f"{{{self.NAMESPACE}}}type", + "http://www.w3.org/2001/XMLSchema:string" + ) + elif value_type == "decimal": + value_elem.set( + f"{{{self.NAMESPACE}}}type", + "http://www.w3.org/2001/XMLSchema:decimal" + ) + elif value_type == "dateTime": + value_elem.set( + f"{{{self.NAMESPACE}}}type", + "http://www.w3.org/2001/XMLSchema:dateTime" + ) + + value_elem.text = value + + # ========== Работа с организациями ========== + + def _add_organization_properties( + self, + parent: etree.Element, + org_data: Dict, + prefix: str = "Организация" + ) -> None: + """Добавление свойств организации""" + self._add_property( + parent, + f"{prefix}Наименование", + self._safe_get(org_data, "name") + ) + self._add_property( + parent, + f"{prefix}ИНН", + self._safe_get(org_data, "inn") + ) + + kpp = self._safe_get(org_data, "kpp") + if kpp: + self._add_property(parent, f"{prefix}КПП", kpp) + + self._add_property( + parent, + f"{prefix}ЮрАдрес", + self._safe_get(org_data, "legalAddress") + ) + + def _add_counterparty_properties( + self, + parent: etree.Element, + counterparty: Dict + ) -> None: + """Добавление свойств контрагента""" + self._add_organization_properties( + parent, + counterparty, + prefix="Контрагент" + ) + + # ========== Работа с товарами ========== + + def _add_product_item( + self, + parent: etree.Element, + position: Dict, + index: int + ) -> None: + """Добавление позиции товара в таблицу""" + row = etree.SubElement(parent, f"{{{self.NAMESPACE}}}Row") + + # Номер строки + self._add_property(row, "НомерСтроки", str(index)) + + # Наименование товара + product_name = self._safe_get(position, "name") + self._add_property(row, "Номенклатура", product_name) + + # Количество + quantity = position.get("quantity", 0) + self._add_property(row, "Количество", str(quantity), "decimal") + + # Цена + price = self._format_money(position.get("price", 0)) + self._add_property(row, "Цена", price, "decimal") + + # Сумма + total = self._format_money( + quantity * position.get("price", 0) + ) + self._add_property(row, "Сумма", total, "decimal") + + # НДС + vat = position.get("vat", 0) + if vat: + vat_rate = vat + vat_sum = self._format_money( + int(quantity * position.get("price", 0) * vat / 100) + ) + self._add_property(row, "СтавкаНДС", str(vat_rate)) + self._add_property(row, "СуммаНДС", vat_sum, "decimal") + + def _add_products_table( + self, + parent: etree.Element, + positions: List[Dict] + ) -> None: + """Добавление таблицы товаров""" + table = etree.SubElement(parent, f"{{{self.NAMESPACE}}}Property") + etree.SubElement(table, f"{{{self.NAMESPACE}}}Name").text = "Товары" + + value = etree.SubElement(table, f"{{{self.NAMESPACE}}}Value") + value.set( + f"{{{self.NAMESPACE}}}type", + f"{{{self.NAMESPACE}}}ValueTable" + ) + + for idx, position in enumerate(positions, start=1): + self._add_product_item(value, position, idx) + + # ========== Работа с суммами ========== + + def _calculate_totals(self, positions: List[Dict]) -> Dict[str, float]: + """Расчет итоговых сумм""" + total_sum = 0 + total_vat = 0 + + for position in positions: + quantity = position.get("quantity", 0) + price = position.get("price", 0) + vat = position.get("vat", 0) + + item_sum = quantity * price + total_sum += item_sum + + if vat: + total_vat += int(item_sum * vat / 100) + + return { + "sum": self._format_money(int(total_sum)), + "vat": self._format_money(total_vat), + "total": self._format_money(int(total_sum) + total_vat) + } + + def _add_totals( + self, + parent: etree.Element, + totals: Dict[str, float] + ) -> None: + """Добавление итоговых сумм""" + self._add_property( + parent, + "СуммаДокумента", + totals["sum"], + "decimal" + ) + self._add_property( + parent, + "СуммаНДС", + totals["vat"], + "decimal" + ) + self._add_property( + parent, + "СуммаВсего", + totals["total"], + "decimal" + ) + + # ========== Генерация документов ========== + + def _create_document_base( + self, + doc_type: str, + order_data: Dict + ) -> etree.Element: + """Создание базовой структуры документа""" + root = etree.Element( + f"{{{self.NAMESPACE}}}Data", + nsmap=self.nsmap + ) + + # Основной объект документа + obj = etree.SubElement(root, f"{{{self.NAMESPACE}}}Object") + + # Тип документа + self._add_property(obj, "ТипДокумента", doc_type) + + # Номер и дата + self._add_property( + obj, + "Номер", + self._safe_get(order_data, "name") + ) + self._add_property( + obj, + "Дата", + self._format_date(self._safe_get(order_data, "moment")), + "dateTime" + ) + + return obj + + def _add_document_parties( + self, + obj: etree.Element, + organization: Dict, + counterparty: Dict + ) -> None: + """Добавление информации о сторонах сделки""" + self._add_organization_properties(obj, organization) + self._add_counterparty_properties(obj, counterparty) + + def generate_realization( + self, + order_data: Dict, + filename: Optional[str] = None + ): + """ + Генерация XML документа реализации товаров + + Args: + order_data: Данные о заказе + filename: Имя выходного файла (опционально) + + Returns: + Если filename указан - True/False (успех/ошибка) + Если filename не указан - XML строка или None при ошибке + """ + try: + logger.info( + f"Генерация XML реализации для заказа " + f"{order_data.get('name', 'N/A')}" + ) + + # Базовая структура + obj = self._create_document_base( + "РеализацияТоваровУслуг", + order_data + ) + + # Организация и контрагент + organization = order_data.get("organization", {}) + agent = order_data.get("agent", {}) + + self._add_document_parties(obj, organization, agent) + + # Товары + positions = order_data.get("positions", []) + self._add_products_table(obj, positions) + + # Итоговые суммы + totals = self._calculate_totals(positions) + self._add_totals(obj, totals) + + # Получаем XML + root = obj.getparent() + tree = etree.ElementTree(root) + + if filename: + # Сохранение в файл + tree.write( + filename, + encoding=self.encoding, + xml_declaration=True, + pretty_print=True + ) + logger.info(f"XML успешно сохранен: {filename}") + return True + else: + # Возврат XML строки + xml_str = etree.tostring( + root, + encoding=self.encoding, + xml_declaration=True, + pretty_print=True + ).decode(self.encoding) + logger.info("XML успешно сгенерирован") + return xml_str + + except Exception as e: + logger.error(f"Ошибка генерации XML: {e}", exc_info=True) + return None if filename is None else False + + def generate_batch_realization( + self, + orders: List[Dict], + filename: str + ) -> bool: + """ + Генерация пакетного XML с несколькими документами + + Args: + orders: Список заказов + filename: Имя выходного файла + + Returns: + True если генерация успешна + """ + try: + logger.info(f"Генерация пакетного XML для {len(orders)} заказов") + + root = etree.Element( + f"{{{self.NAMESPACE}}}Data", + nsmap=self.nsmap + ) + + for order_data in orders: + # Создаем объект документа для каждого заказа + obj = etree.SubElement( + root, + f"{{{self.NAMESPACE}}}Object" + ) + + # Тип документа + self._add_property( + obj, + "ТипДокумента", + "РеализацияТоваровУслуг" + ) + + # Номер и дата + self._add_property( + obj, + "Номер", + self._safe_get(order_data, "name") + ) + self._add_property( + obj, + "Дата", + self._format_date(self._safe_get(order_data, "moment")), + "dateTime" + ) + + # Стороны сделки + organization = order_data.get("organization", {}) + agent = order_data.get("agent", {}) + self._add_document_parties(obj, organization, agent) + + # Товары + positions = order_data.get("positions", []) + self._add_products_table(obj, positions) + + # Итоги + totals = self._calculate_totals(positions) + self._add_totals(obj, totals) + + # Сохранение + tree = etree.ElementTree(root) + tree.write( + filename, + encoding=self.encoding, + xml_declaration=True, + pretty_print=True + ) + + logger.info(f"Пакетный XML успешно сохранен: {filename}") + return True + + except Exception as e: + logger.error( + f"Ошибка генерации пакетного XML: {e}", + exc_info=True + ) + return False + + +# Для обратной совместимости +XMLGenerator = XML1CGenerator diff --git a/requirements_gui.txt b/requirements_gui.txt new file mode 100644 index 0000000..942efe6 --- /dev/null +++ b/requirements_gui.txt @@ -0,0 +1,33 @@ +# Зависимости для GUI приложения +# Все библиотеки будут включены в standalone exe + +# GUI Framework +PyQt6==6.6.1 +PyQt6-Qt6==6.6.1 +PyQt6-sip==13.6.0 + +# HTTP клиент +requests==2.31.0 +urllib3==2.1.0 +certifi==2023.11.17 +charset-normalizer==3.3.2 +idna==3.6 + +# XML обработка +lxml==5.1.0 + +# Шифрование +cryptography==41.0.7 +cffi==1.16.0 +pycparser==2.21 + +# Сборка exe +pyinstaller==6.3.0 +pyinstaller-hooks-contrib==2024.0 +altgraph==0.17.4 +packaging==23.2 +pefile==2023.2.7 +pywin32-ctypes==0.2.2 + +# Утилиты +python-dotenv==1.0.0 diff --git a/start.bat b/start.bat deleted file mode 100644 index b1c5bb5..0000000 --- a/start.bat +++ /dev/null @@ -1,47 +0,0 @@ -@echo off -REM Скрипт для запуска бота в Docker (Windows) - -echo 🚀 Запуск BUH Bot в Docker... - -REM Проверка наличия .env файла -if not exist .env ( - echo ❌ Файл .env не найден! - echo 📝 Создайте .env на основе .env.example: - echo copy .env.example .env - echo notepad .env - exit /b 1 -) - -REM Создание необходимых директорий -echo 📁 Создание директорий для данных... -if not exist data mkdir data -if not exist logs mkdir logs -if not exist temp_documents mkdir temp_documents - -REM Сборка образа -echo 🔨 Сборка Docker образа... -docker-compose build - -REM Запуск контейнера -echo ▶️ Запуск контейнера... -docker-compose up -d - -REM Проверка статуса -echo. -echo ✅ Бот запущен! -echo. -echo 📊 Статус контейнера: -docker-compose ps - -echo. -echo 📝 Просмотр логов: -echo docker-compose logs -f -echo. -echo 🛑 Остановка бота: -echo docker-compose down -echo. -echo 🔄 Перезапуск бота: -echo docker-compose restart -echo. - -pause diff --git a/start.sh b/start.sh deleted file mode 100644 index eab8ba4..0000000 --- a/start.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash -# Скрипт для запуска бота в Docker - -set -e - -echo "🚀 Запуск BUH Bot в Docker..." - -# Проверка наличия .env файла -if [ ! -f .env ]; then - echo "❌ Файл .env не найден!" - echo "📝 Создайте .env на основе .env.example:" - echo " cp .env.example .env" - echo " nano .env # и заполните TELEGRAM_BOT_TOKEN" - exit 1 -fi - -# Создание необходимых директорий -echo "📁 Создание директорий для данных..." -mkdir -p data logs temp_documents - -# Сборка образа -echo "🔨 Сборка Docker образа..." -docker-compose build - -# Запуск контейнера -echo "▶️ Запуск контейнера..." -docker-compose up -d - -# Проверка статуса -echo "" -echo "✅ Бот запущен!" -echo "" -echo "📊 Статус контейнера:" -docker-compose ps - -echo "" -echo "📝 Просмотр логов:" -echo " docker-compose logs -f" -echo "" -echo "🛑 Остановка бота:" -echo " docker-compose down" -echo "" -echo "🔄 Перезапуск бота:" -echo " docker-compose restart" diff --git a/start_bot.bat b/start_bot.bat deleted file mode 100644 index 3133c01..0000000 --- a/start_bot.bat +++ /dev/null @@ -1,27 +0,0 @@ -@echo off -REM Скрипт быстрого запуска Telegram бота (Windows) - -echo 🤖 Запуск Telegram бота для МойСклад... -echo. - -REM Проверка виртуального окружения -if not exist ".venv" ( - echo ❌ Виртуальное окружение не найдено! - echo Создайте его командой: python -m venv .venv - exit /b 1 -) - -REM Проверка .env файла -if not exist ".env" ( - echo ❌ Файл .env не найден! - echo Скопируйте .env.example в .env и заполните параметры: - echo copy .env.example .env - exit /b 1 -) - -REM Активация и запуск -echo ✅ Запуск бота... -echo. - -call .venv\Scripts\activate.bat -python bot.py diff --git a/start_bot.sh b/start_bot.sh deleted file mode 100644 index d8d8a78..0000000 --- a/start_bot.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash -# Скрипт быстрого запуска Telegram бота - -echo "🤖 Запуск Telegram бота для МойСклад..." -echo "" - -# Проверка виртуального окружения -if [ ! -d ".venv" ]; then - echo "❌ Виртуальное окружение не найдено!" - echo "Создайте его командой: python -m venv .venv" - exit 1 -fi - -# Проверка .env файла -if [ ! -f ".env" ]; then - echo "❌ Файл .env не найден!" - echo "Скопируйте .env.example в .env и заполните параметры:" - echo " cp .env.example .env" - exit 1 -fi - -# Активация виртуального окружения и запуск -echo "✅ Запуск бота..." -echo "" - -if [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]]; then - # Windows - source .venv/Scripts/activate -else - # Linux/Mac - source .venv/bin/activate -fi - -python bot.py diff --git a/stop.bat b/stop.bat deleted file mode 100644 index 3893c4f..0000000 --- a/stop.bat +++ /dev/null @@ -1,16 +0,0 @@ -@echo off -REM Скрипт для остановки бота (Windows) - -echo 🛑 Остановка BUH Bot... -docker-compose down - -echo. -echo ✅ Бот остановлен! -echo. -echo 💾 Данные сохранены в: -echo - .\data\users.json -echo - .\data\.encryption_key -echo - .\logs\ -echo. - -pause diff --git a/stop.sh b/stop.sh deleted file mode 100644 index c300f41..0000000 --- a/stop.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash -# Скрипт для остановки бота - -echo "🛑 Остановка BUH Bot..." -docker-compose down - -echo "" -echo "✅ Бот остановлен!" -echo "" -echo "💾 Данные сохранены в:" -echo " - ./data/users.json" -echo " - ./data/.encryption_key" -echo " - ./logs/" diff --git a/test_final.xml b/test_final.xml deleted file mode 100644 index b8306c5..0000000 --- a/test_final.xml +++ /dev/null @@ -1,144 +0,0 @@ - - - - http://v8.1c.ru/edi/edi_stnd/EnterpriseData/1.20 - 2025-11-06T07:47:55 - 1.20 - - - <Документ.РеализацияТоваровУслуг> - <КлючевыеСвойства> - <Ссылка> - <Дата>2024-01-15T10:30:00 - <Номер>TEST-123 - <Организация> - <Ссылка>11111111-2222-3333-4444-555555555555 - <Наименование>ООО Тест - <НаименованиеСокращенное>ООО Тест - <НаименованиеПолное> - <ИНН>1234567890 - <ЮридическоеФизическоеЛицо>ЮридическоеЛицо - - - <ВидОперации>РеализацияКлиенту - <Валюта> - <Ссылка>72357d18-63a8-11e8-80dd-0050569f5448 - <ДанныеКлассификатора> - <Код>643 - <Наименование>руб. - - - <Сумма>100 - <СуммаВключаетНДС>true - <Контрагент> - <Ссылка>66666666-7777-8888-9999-000000000000 - <Наименование>Покупатель Тест - <НаименованиеПолное>Покупатель Тест - <ИНН>9876543210 - <ЮридическоеФизическоеЛицо>ЮридическоеЛицо - <СтранаРегистрации> - <Ссылка>67700c1b-63a8-11e8-80dd-0050569f5448 - <ДанныеКлассификатора> - <Код>643 - <Наименование>РОССИЯ - - - - <ДанныеВзаиморасчетов> - <Договор> - <Ссылка>f7fab3b0-67d6-58c4-a0c4-9438ef29a016 - <ВидДоговора>СПокупателем - <Организация> - <Ссылка>11111111-2222-3333-4444-555555555555 - <Наименование>ООО Тест - <НаименованиеСокращенное>ООО Тест - <НаименованиеПолное> - <ИНН>1234567890 - <ЮридическоеФизическоеЛицо>ЮридическоеЛицо - - <Контрагент> - <Ссылка>66666666-7777-8888-9999-000000000000 - <Наименование>Покупатель Тест - <НаименованиеПолное> - <ИНН>9876543210 - <ЮридическоеФизическоеЛицо>ЮридическоеЛицо - <СтранаРегистрации> - <Ссылка>67700c1b-63a8-11e8-80dd-0050569f5448 - <ДанныеКлассификатора> - <Код>643 - <Наименование>РОССИЯ - - - - <ВалютаВзаиморасчетов> - <Ссылка>72357d18-63a8-11e8-80dd-0050569f5448 - <ДанныеКлассификатора> - <Код>643 - <Наименование>руб. - - - <РасчетыВУсловныхЕдиницах>false - <Наименование>Основной договор - - <ВалютаВзаиморасчетов> - <Ссылка>72357d18-63a8-11e8-80dd-0050569f5448 - <ДанныеКлассификатора> - <Код>643 - <Наименование>руб. - - - <КурсВзаиморасчетов>1 - <КратностьВзаиморасчетов>1 - <РасчетыВУсловныхЕдиницах>false - - <Налогообложение>ПродажаОблагаетсяНДС - <ВидЭД>ТОРГ12 - <Товары> - <Строка> - <ДанныеНоменклатуры> - <Номенклатура> - <Ссылка>aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee - <НаименованиеПолное>Товар тест - <КодВПрограмме>TEST001 - <Наименование>Товар тест - - - <ЕдиницаИзмерения> - <Ссылка>cdf130e2-63a8-11e8-80dd-0050569f5448 - <ДанныеКлассификатора> - <Код>796 - <Наименование>шт - - - <Количество>1 - <Сумма>100 - <Цена>100 - <СтавкаНДС> - <Ставка>20 - <РасчетнаяСтавка>false - <НеОблагается>false - <ВидСтавки>Общая - <Страна> - <Ссылка>67700c1b-63a8-11e8-80dd-0050569f5448 - <ДанныеКлассификатора> - <Код>643 - <Наименование>РОССИЯ - - - - <СуммаНДС>16.67 - <ТипЗапасов>СобственныеТовары - <СчетУчета>43 - <СчетДоходов>90.01.1 - <СчетРасходов>90.02.1 - <СчетУчетаНДСПоРеализации>90.03 - <ИдентификаторСтроки>61234b12-58a2-44f0-8751-f071c2cfc38c - <ПрослеживаемыйТовар>false - - - <СпособПогашенияЗадолженности>Автоматически - <КомплектПечатныхДокументовПодписанПолучен>false - <ЭтоУПД>false - - - diff --git a/test_fixed.xml b/test_fixed.xml deleted file mode 100644 index f0ae793..0000000 --- a/test_fixed.xml +++ /dev/null @@ -1,139 +0,0 @@ - - - - http://v8.1c.ru/edi/edi_stnd/EnterpriseData/1.20 - 2025-11-06T08:08:20 - 1.20 - - - <Документ.РеализацияТоваровУслуг> - <КлючевыеСвойства> - <Ссылка> - <Дата>2024-01-15T10:30:00 - <Номер>TEST-FIX - <Организация> - <Ссылка>11111111-2222-3333-4444-555555555555 - <Наименование>ООО Тест - <НаименованиеСокращенное>ООО Тест - <НаименованиеПолное> - <ИНН>1234567890 - <ЮридическоеФизическоеЛицо>ЮридическоеЛицо - - - <ВидОперации>РеализацияКлиенту - <Валюта> - <Ссылка>72357d18-63a8-11e8-80dd-0050569f5448 - <ДанныеКлассификатора> - <Код>643 - <Наименование>руб. - - - <Сумма>100 - <СуммаВключаетНДС>true - <Контрагент> - <Ссылка>66666666-7777-8888-9999-000000000000 - <Наименование>Покупатель Тест - <НаименованиеПолное>Покупатель Тест - <ИНН>9876543210 - <ЮридическоеФизическоеЛицо>ЮридическоеЛицо - <СтранаРегистрации> - <Ссылка>67700c1b-63a8-11e8-80dd-0050569f5448 - <ДанныеКлассификатора> - <Код>643 - <Наименование>РОССИЯ - - - - <ДанныеВзаиморасчетов> - <Договор> - <Ссылка>f7fab3b0-67d6-58c4-a0c4-9438ef29a016 - <ВидДоговора>СПокупателем - <Организация> - <Ссылка>11111111-2222-3333-4444-555555555555 - <Наименование>ООО Тест - <НаименованиеСокращенное>ООО Тест - <НаименованиеПолное> - <ИНН>1234567890 - <ЮридическоеФизическоеЛицо>ЮридическоеЛицо - - <Контрагент> - <Ссылка>66666666-7777-8888-9999-000000000000 - <Наименование>Покупатель Тест - <НаименованиеПолное> - <ИНН>9876543210 - <ЮридическоеФизическоеЛицо>ЮридическоеЛицо - <СтранаРегистрации> - <Ссылка>67700c1b-63a8-11e8-80dd-0050569f5448 - <ДанныеКлассификатора> - <Код>643 - <Наименование>РОССИЯ - - - - <ВалютаВзаиморасчетов> - <Ссылка>72357d18-63a8-11e8-80dd-0050569f5448 - <ДанныеКлассификатора> - <Код>643 - <Наименование>руб. - - - <РасчетыВУсловныхЕдиницах>false - <Наименование>Основной договор - - <ВалютаВзаиморасчетов> - <Ссылка>72357d18-63a8-11e8-80dd-0050569f5448 - <ДанныеКлассификатора> - <Код>643 - <Наименование>руб. - - - <КурсВзаиморасчетов>1 - <КратностьВзаиморасчетов>1 - <РасчетыВУсловныхЕдиницах>false - - <Налогообложение>ПродажаОблагаетсяНДС - <Товары> - <Строка> - <ДанныеНоменклатуры> - <Номенклатура> - <Ссылка>aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee - <НаименованиеПолное>Товар с копейками - <КодВПрограмме>TEST001 - <Наименование>Товар с копейками - - - <ЕдиницаИзмерения> - <Ссылка>cdf130e2-63a8-11e8-80dd-0050569f5448 - <ДанныеКлассификатора> - <Код>796 - <Наименование>шт - - - <Количество>10 - <Сумма>12345.60 - <Цена>1234.56 - <СтавкаНДС> - <Ставка>20 - <РасчетнаяСтавка>false - <НеОблагается>false - <ВидСтавки>Общая - <Страна> - <Ссылка>67700c1b-63a8-11e8-80dd-0050569f5448 - <ДанныеКлассификатора> - <Код>643 - <Наименование>РОССИЯ - - - - <СуммаНДС>2057.60 - <ТипЗапасов>СобственныеТовары - <ИдентификаторСтроки>e7f4517f-41c6-4f39-acee-23ed0a3177d6 - <ПрослеживаемыйТовар>false - - - <СпособПогашенияЗадолженности>Автоматически - <КомплектПечатныхДокументовПодписанПолучен>false - <ЭтоУПД>false - - - diff --git a/test_order_4926.xml b/test_order_4926.xml deleted file mode 100644 index 62b9f18..0000000 --- a/test_order_4926.xml +++ /dev/null @@ -1,89 +0,0 @@ - -<Файл ВерсПрог="BUH_BOT_1.0" ВерсФорм="5.03"> - <Документ> - <СвСчФакт> - <НомерДата Номер="4926" Дата="06.11.2025"/> - <СвПрод ОКПО="0142109827"> - <ИдСв> - <СвИП ИННФЛ="263605160270" СвГосРегИП="304263530000073"> - <ФИО Фамилия="Беляев" Имя="Андрей" Отчество="Юрьевич"/> - - - <Адрес> - <АдрТекст Текст="355000, Россия, Ставропольский край, г Ставрополь"/> - - <БанкРекв НомерСчета="40802810860270001979"> - <СвБанк НаимБанк="СТАВРОПОЛЬСКОЕ ОТДЕЛЕНИЕ N5230 ПАО СБЕРБАНК" БИК="040702615" КорСчет="30101810907020000615"/> - - <Контакт> - <Тлф>8-800-234-33-68 - <ЭлПочта>robotail@bk.ru - - - <ГрузОт> - <ОнЖе>он же - - <ГрузПолуч> - <ИдСв СвЮЛУч="" КПП=""> - <НаимОрг Наим="Котов Антон Максимович"/> - - <Контакт> - <Тлф>+79218378869 - - - <ДокПодтвОтгрНом РеквНаимДок="УПД" РеквНомерДок="4926" РеквДатаДок="06.11.2025"/> - <СвПокуп> - <ИдСв СвЮЛУч="" КПП=""> - <НаимОрг Наим="Котов Антон Максимович"/> - - <Контакт> - <Тлф>+79218378869 - <ЭлПочта>77alpha@mail.ru - - - <ДенИзм КодОКВ="643" НаимОКВ="Российский рубль"/> - - <ТаблСчФакт> - <СведТов НомСтр="1"> - <НаимТов Наим="Камера для силомера Rocky Boxer"/> - <ОКЕИ_Тов НаимЕдИзм="шт"/> - <КолТов Количество="3.0"/> - <ЦенаТов Цена="390.00"/> - <СтТовБезНДС СтоимБезНДС="1114.29"/> - <НалСт СтавНДС="5" СумНДС="55.71"/> - <СтТовУчНал СтоимУчНДС="1170.00"/> - - <СведТов НомСтр="2"> - <НаимТов Наим="Кожаный чехол для груши (Черный)"/> - <ОКЕИ_Тов НаимЕдИзм="шт"/> - <КолТов Количество="2.0"/> - <ЦенаТов Цена="7810.00"/> - <СтТовБезНДС СтоимБезНДС="14876.19"/> - <НалСт СтавНДС="5" СумНДС="743.81"/> - <СтТовУчНал СтоимУчНДС="15620.00"/> - - <СведТов НомСтр="3"> - <НаимТов Наим="Полиуретановый конус с усиленной оксидированной шпилькой"/> - <ОКЕИ_Тов НаимЕдИзм="шт"/> - <КолТов Количество="2.0"/> - <ЦенаТов Цена="2680.00"/> - <СтТовБезНДС СтоимБезНДС="5104.76"/> - <НалСт СтавНДС="5" СумНДС="255.24"/> - <СтТовУчНал СтоимУчНДС="5360.00"/> - - <ВсегоОпл> - <СтТовБезНДСВсего СтоимБезНДС="21095.24"/> - <СумНДСВсего СумНДС="1054.76"/> - <СтТовУчНалВсего СтоимУчНДС="22150.00"/> - - - <СвПродПер> - <СвПер СодОпер="Товары переданы" ДатаПер="06.11.2025"> - <БезДокОснПер>1 - - - <Подписант СпосПодтПолном="6"> - <ФИО Фамилия="Беляев" Имя="Андрей" Отчество="Юрьевич"/> - - -