Compare commits

..

3 Commits

Author SHA1 Message Date
1c14f9bcc1 docker-ready 2025-07-22 01:15:08 +03:00
c2ff6d8aad tv fix 2025-07-22 00:50:37 +03:00
f9e5d73868 added 2025-07-21 23:52:53 +03:00
27 changed files with 257 additions and 443 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
# .dockerignore
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.env
*.db
*.db-journal
test_output/
logs/
.git
.gitignore
.idea/

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ docker-compose.yml
*.db *.db
__* __*
*.json *.json
files/*

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
# Этап 1: Используем официальный легковесный образ Python
FROM python:3.9-slim
# Устанавливаем рабочую директорию внутри контейнера
WORKDIR /app
# Обновляем pip и устанавливаем зависимости
# Копируем requirements.txt отдельно, чтобы Docker мог кэшировать этот слой.
# Зависимости переустановятся только если изменится requirements.txt.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Копируем весь исходный код приложения в рабочую директорию
COPY . .
# Указываем команду, которая будет выполняться при запуске контейнера
CMD ["python", "main.py"]

View File

@@ -1,4 +1,3 @@
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -12,6 +11,8 @@ if not all([SD_ACCESS_KEY, SD_BASE_URL]):
raise ValueError("Необходимо задать переменные окружения SDKEY и SD_BASE_URL") raise ValueError("Необходимо задать переменные окружения SDKEY и SD_BASE_URL")
# --- Paths --- # --- Paths ---
# Директория для хранения лог-файлов
LOG_PATH = os.getenv("LOGPATH", ".")
DB_PATH = os.getenv("BDPATH", ".") # По умолчанию - текущая папка DB_PATH = os.getenv("BDPATH", ".") # По умолчанию - текущая папка
DB_NAME = "fiscals.db" DB_NAME = "fiscals.db"
DB_FULL_PATH = os.path.join(DB_PATH, DB_NAME) DB_FULL_PATH = os.path.join(DB_PATH, DB_NAME)

View File

@@ -115,7 +115,8 @@ class DatabaseManager:
uuid TEXT PRIMARY KEY, uuid TEXT PRIMARY KEY,
owner_uuid TEXT, owner_uuid TEXT,
clean_anydesk_id TEXT, clean_anydesk_id TEXT,
clean_teamviewer_id TEXT clean_teamviewer_id TEXT,
lastModifiedDate TEXT
)""") )""")
# Новая таблица для кэширования UUID справочников # Новая таблица для кэширования UUID справочников

View File

@@ -1,23 +0,0 @@
{
"modelName": "АТОЛ 25Ф",
"serialNumber": "00105707796831",
"RNM": "0004421585034085",
"organizationName": "ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ \"МЕГА СЕРВИС\"",
"fn_serial": "9287440301117169",
"datetime_reg": "2021-06-17 17:01:00",
"dateTime_end": "2024-07-01 00:00:00",
"ofdName": "АО <КАЛУГА АСТРАЛ>",
"bootVersion": "3.0.8319",
"ffdVersion": "105",
"INN": "7730255999",
"attribute_excise": "False",
"attribute_marked": "Не поддерживается в текущей версии драйвера",
"fnExecution": "Не поддерживается в текущей версии драйвера",
"hostname": "13CASH08",
"url_rms": "https://aom-himki.iiko.it:443/resto",
"teamviever_id": "1050831481",
"anydesk_id": "334171076",
"total_space_sys": "69.48 Gb",
"free_space_sys": "45.86 Gb",
"current_time": "2024-05-08 20:42:24"
}

View File

@@ -1,23 +0,0 @@
{
"modelName": "АТОЛ 30Ф",
"serialNumber": "00106126844935",
"RNM": "0007195642054348",
"organizationName": "ИП Зайцев Егор Владимирович",
"fn_serial": "7281440500463082",
"datetime_reg": "2023-04-24 21:05:00",
"dateTime_end": "2024-06-07 00:00:00",
"ofdName": "ООО Такском",
"bootVersion": "5.8.100",
"ffdVersion": "120",
"INN": "110120364802",
"attribute_excise": "True",
"attribute_marked": "True",
"fnExecution": "ФН-1.2 исполнение Ин15-3 ",
"hostname": "AVE-SHAVE-GK",
"url_rms": "https://ave-shawe.iiko.it:443/resto",
"teamviever_id": "None",
"anydesk_id": "391033027",
"total_space_sys": "58.13 Gb",
"free_space_sys": "27.29 Gb",
"current_time": "2024-05-09 14:15:56"
}

View File

@@ -1,23 +0,0 @@
{
"modelName": "АТОЛ 55Ф",
"serialNumber": "00106202123911",
"RNM": "0002593555046068",
"organizationName": "ООО \"ТЕАТРАЛЬНАЯ\"",
"fn_serial": "7284440500258734",
"datetime_reg": "2023-06-30 14:08:00",
"dateTime_end": "2024-08-13 00:00:00",
"ofdName": "сбис",
"bootVersion": "5.8.100",
"ffdVersion": "120",
"INN": "6827024224 ",
"attribute_excise": "True",
"attribute_marked": "True",
"fnExecution": "ФН-1.2 исполнение Ав15-3 ",
"hostname": "User-PC80",
"url_rms": "https://chainik-cloud.iiko.it:443/resto",
"teamviever_id": "593526432",
"anydesk_id": "1919438899",
"total_space_sys": "111.25 Gb",
"free_space_sys": "73.01 Gb",
"current_time": "2024-05-07 18:20:55"
}

View File

@@ -1,23 +0,0 @@
{
"modelName": "АТОЛ FPrint-22ПТК",
"serialNumber": "00106302050149",
"RNM": "0000839196015549",
"organizationName": "ИП АВАНЕСОВА НАТАЛЬЯ НОРДЕЕВНА",
"fn_serial": "7281440501031166",
"datetime_reg": "2023-05-28 10:18:00",
"dateTime_end": "2024-07-11 00:00:00",
"ofdName": "ООО \"Такском\"",
"bootVersion": "5.8.100",
"ffdVersion": "120",
"INN": "507901506303",
"attribute_excise": "True",
"attribute_marked": "True",
"fnExecution": "ФН-1.2 исполнение Ин15-3 ",
"hostname": "RASSKAZOVKA_BAGET",
"url_rms": "http://88.99.60.29:8187/resto",
"teamviever_id": "1145765525",
"anydesk_id": "952794502",
"total_space_sys": "59.62 Gb",
"free_space_sys": "7.44 Gb",
"current_time": "2024-05-06 02:02:23"
}

View File

@@ -1,23 +0,0 @@
{
"modelName": "АТОЛ FPrint-22ПТК",
"serialNumber": "00106309062146",
"RNM": "0005464060042782",
"organizationName": "ИП ИЛЬЕНКО МАКСИМ МИХАЙЛОВИЧ",
"fn_serial": "9287440301158465",
"datetime_reg": "2021-05-21 16:35:00",
"dateTime_end": "2024-06-04 00:00:00",
"ofdName": "ООО \"Эвотор ОФД\"",
"bootVersion": "5.8.100",
"ffdVersion": "105",
"INN": "501300190904",
"attribute_excise": "False",
"attribute_marked": "Не поддерживается в текущей версии драйвера",
"fnExecution": "Не поддерживается в текущей версии драйвера",
"hostname": "Kaldis_ST-8",
"url_rms": "https://kaldis-st-8.iiko.it:443/resto",
"teamviever_id": "130045574",
"anydesk_id": "856695376",
"total_space_sys": "59.62 Gb",
"free_space_sys": "26.18 Gb",
"current_time": "2024-05-09 01:11:30"
}

View File

@@ -1,23 +0,0 @@
{
"modelName": "АТОЛ FPrint-22ПТК",
"serialNumber": "00106309239401",
"RNM": "0005463930014863",
"organizationName": "ИП ИЛЬЕНКО МАКСИМ МИХАЙЛОВИЧ",
"fn_serial": "9287440301158440",
"datetime_reg": "2021-05-21 15:58:00",
"dateTime_end": "2024-06-04 00:00:00",
"ofdName": "ООО \"Эвотор ОФД\"",
"bootVersion": "5.8.100",
"ffdVersion": "105",
"INN": "501300190904",
"attribute_excise": "False",
"attribute_marked": "False",
"fnExecution": "",
"hostname": "Kaldis_ST-7",
"url_rms": "https://kaldis-st-7.iiko.it:443/resto",
"teamviever_id": "130042147",
"anydesk_id": "595633117",
"total_space_sys": "59.62 Gb",
"free_space_sys": "20.22 Gb",
"current_time": "2024-05-06 01:41:41"
}

View File

@@ -1,23 +0,0 @@
{
"modelName": "АТОЛ FPrint-22ПТК",
"serialNumber": "00106309300916",
"RNM": "0006138955056715",
"organizationName": "ИП ИЛЬЕНКО МАРИЯ ВЛАДИМИРОВНА",
"fn_serial": "7281440701572965",
"datetime_reg": "2024-04-23 21:49:00",
"dateTime_end": "2025-06-07 00:00:00",
"ofdName": "ООО \"Эвотор ОФД\"",
"bootVersion": "5.8.100",
"ffdVersion": "120",
"INN": "504005415507",
"attribute_excise": "True",
"attribute_marked": "True",
"fnExecution": "ФН-1.2 исполнение Ин15-3 ",
"hostname": "POS-Lianozovo",
"url_rms": "https://kaldis-lianozovo.iiko.it:443/resto",
"teamviever_id": "1493401222",
"anydesk_id": "668050369",
"total_space_sys": "59.14 Gb",
"free_space_sys": "7.49 Gb",
"current_time": "2024-05-09 23:16:42"
}

View File

@@ -1,23 +0,0 @@
{
"modelName": "АТОЛ FPrint-22ПТК",
"serialNumber": "00106309330871",
"RNM": "0002792971023790",
"organizationName": "ООО \"ФинЦентр\"",
"fn_serial": "9961440300055208",
"datetime_reg": "2023-02-23 09:04:00",
"dateTime_end": "2026-03-09 00:00:00",
"ofdName": "АО <Калуга Астрал>",
"bootVersion": "5.8.100",
"ffdVersion": "120",
"INN": "7743884031 ",
"attribute_excise": "False",
"attribute_marked": "True",
"fnExecution": "ФН-1.1М исполнение Ин36-1М ",
"hostname": "POS-W10",
"url_rms": "https://press-bar-moskva-m-servis.iiko.it:443/resto",
"teamviever_id": "1231759409",
"anydesk_id": "407482388",
"total_space_sys": "59.62 Gb",
"free_space_sys": "28.14 Gb",
"current_time": "2024-05-06 12:25:56"
}

View File

@@ -1,23 +0,0 @@
{
"modelName": "АТОЛ 77Ф",
"serialNumber": "00106905442664",
"RNM": "0001326280045003",
"organizationName": "АВАНЕСОВА НАТАЛЬЯ НОРДЕЕВНА",
"fn_serial": "7380440700519749",
"datetime_reg": "2024-04-05 14:58:00",
"dateTime_end": "2025-05-20 00:00:00",
"ofdName": "ООО <Такском>",
"bootVersion": "5.8.100",
"ffdVersion": "120",
"INN": "507901506303",
"attribute_excise": "True",
"attribute_marked": "True",
"fnExecution": "ФН-1.2 исполнение Ин15-4 ",
"hostname": "DESKTOP-IUSVUSE",
"url_rms": "http://88.99.60.29:8187/resto",
"teamviever_id": "1284989181",
"anydesk_id": "None",
"total_space_sys": "59.04 Gb",
"free_space_sys": "18.64 Gb",
"current_time": "2024-05-06 02:08:13"
}

View File

@@ -1,23 +0,0 @@
{
"modelName": "АТОЛ 20Ф",
"serialNumber": "00108100739722",
"RNM": "0001781789038839",
"organizationName": "ИП Пилин Игорь Андреевич",
"fn_serial": "9961440300916272",
"datetime_reg": "2022-08-18 08:36:00",
"dateTime_end": "2025-09-01 00:00:00",
"ofdName": "АО \"ЭСК\"",
"bootVersion": "3.0.4253",
"ffdVersion": "105",
"INN": "344309628497",
"attribute_excise": "False",
"attribute_marked": "False",
"fnExecution": "",
"hostname": "WIN-DMS6GB0U6TO",
"url_rms": "https://3-sushi-mira.iiko.it:443/resto",
"teamviever_id": "1285328750",
"anydesk_id": "998297587",
"total_space_sys": "111.38 Gb",
"free_space_sys": "63.19 Gb",
"current_time": "2024-05-09 07:08:03"
}

View File

@@ -1,23 +0,0 @@
{
"modelName": "АТОЛ 20Ф",
"serialNumber": "00108129540393",
"RNM": "0007978093023550",
"organizationName": "ИП Ким Вячеслав",
"fn_serial": "7380440700292820",
"datetime_reg": "2024-04-11 15:18:00",
"dateTime_end": "2025-05-26 00:00:00",
"ofdName": "ООО ПЕТЕР-СЕРВИС Спецтехнологии",
"bootVersion": "5.8.100",
"ffdVersion": "120",
"INN": "650125159002",
"attribute_excise": "True",
"attribute_marked": "True",
"fnExecution": "ФН-1.2 исполнение Ин15-4 ",
"hostname": "KASSA2-KHABAR",
"url_rms": "https://mirine-brosko-habarovsk.iiko.it:443/resto",
"teamviever_id": "None",
"anydesk_id": "1063125576",
"total_space_sys": "59.09 Gb",
"free_space_sys": "34.89 Gb",
"current_time": "2024-05-07 12:27:50"
}

View File

@@ -1,23 +0,0 @@
{
"modelName": "АТОЛ 27Ф",
"serialNumber": "00108722684571",
"RNM": "0005975228045176",
"organizationName": "Индивидуальный предприниматель Аванесова Наталья Нордеевна",
"fn_serial": "7380440700402306",
"datetime_reg": "2024-04-05 14:43:00",
"dateTime_end": "2025-05-20 00:00:00",
"ofdName": "ООО Такском",
"bootVersion": "5.10.0",
"ffdVersion": "120",
"INN": "507901506303",
"attribute_excise": "True",
"attribute_marked": "True",
"fnExecution": "ФН-1.2 исполнение Ин15-4 ",
"hostname": "Burkina-kofe-NEW",
"url_rms": "http://88.99.60.29:8187/resto",
"teamviever_id": "650019532",
"anydesk_id": "1562660748",
"total_space_sys": "59.62 Gb",
"free_space_sys": "34.64 Gb",
"current_time": "2024-05-06 02:16:47"
}

View File

@@ -1,16 +0,0 @@
{
"modelName": "АТОЛ 27Ф",
"serialNumber": "00108729580581",
"RNM": "0007037066025713",
"organizationName": "ИП ДАВЛАТОВ МИРЗОШО РАХМАТШОЕВИЧ",
"fn_serial": "7281440500179066",
"datetime_reg": "2023-02-16 10:39:00",
"dateTime_end": "2024-05-31 00:00:00",
"ofdName": "ООО Эвотор ОФД",
"bootVersion": "5.7.13",
"ffdVersion": "105",
"INN": "670602568722",
"fnExecution": "",
"attribute_podakciz": "False",
"attribute_marked": "False"
}

View File

@@ -1,23 +0,0 @@
{
"modelName": "АТОЛ 22 v2 Ф",
"serialNumber": "00109522991414",
"RNM": "0007882610017388",
"organizationName": "ИП СОКЛАКОВА АННА СЕРГЕЕВНА",
"fn_serial": "7380440700100735",
"datetime_reg": "2024-02-29 13:10:00",
"dateTime_end": "2025-04-14 00:00:00",
"ofdName": "ООО Такском",
"bootVersion": "5.8.17",
"ffdVersion": "120",
"INN": "771315163893",
"attribute_excise": "True",
"attribute_marked": "True",
"fnExecution": "ФН-1.2 исполнение Ин15-4 ",
"hostname": "VESTERDAM",
"url_rms": "https://pokolenie-kofe-vesterdam.iiko.it:443/resto",
"teamviever_id": "743932094",
"anydesk_id": "369655451",
"total_space_sys": "58.13 Gb",
"free_space_sys": "21.30 Gb",
"current_time": "2024-05-09 10:38:44"
}

View File

@@ -1,16 +0,0 @@
{
"modelName": "АТОЛ 22 v2 Ф",
"serialNumber": "00109529077045",
"RNM": "0006899083013508",
"organizationName": "ООО \"ГАВАНА\"",
"fn_serial": "7281440701652919",
"datetime_reg": "2024-03-20 11:43:00",
"dateTime_end": "2025-07-03 00:00:00",
"ofdName": "ООО Эвотор ОФД",
"bootVersion": "5.8.20",
"ffdVersion": "105",
"INN": "5904388065 ",
"fnExecution": "None",
"attribute_podakciz": "True",
"attribute_marked": "False"
}

View File

@@ -1,14 +0,0 @@
{
"modelName": "АТОЛ 22 v2 Ф",
"serialNumber": "00109525422090",
"RNM": "0000000004454545",
"organizationName": "ООО \"Предприятие\"",
"fn_serial": "7380440700425457",
"datetime_reg": "2024-04-02 03:35:00",
"dateTime_end": "2079-12-31 00:00:00",
"ofdName": "ООО \"Эвотор ОФД\"",
"bootVersion": "5.8.100",
"ffdVersion": "120",
"fnExecution": "Эмулятор ФН с поддержкой ФФД 1.2 ",
"INN": "1111222233 "
}

View File

@@ -1,14 +0,0 @@
{
"modelName": "АТОЛ 22 v2 Ф",
"serialNumber": "00109525422090",
"RNM": "0000000001032218",
"organizationName": "ООО \"Предприятие\"",
"fn_serial": "7380440700425457",
"datetime_reg": "2024-04-02 03:35:00",
"dateTime_end": "2057-12-31 00:00:00",
"ofdName": "ООО \"Эвотор ОФД\"",
"bootVersion": "5.8.100",
"ffdVersion": "120",
"fnExecution": "Эмулятор ФН с поддержкой ФФД 1.2 ",
"INN": "1111222233 "
}

View File

@@ -1,14 +0,0 @@
{
"modelName": "АТОЛ 27Ф",
"serialNumber": "00108722318182",
"RNM": "0006025947045252",
"organizationName": "ООО АЛЕКС-СЕРВИС",
"fn_serial": "7281440701487984",
"datetime_reg": "2024-02-27 09:13:00",
"dateTime_end": "2025-04-12 00:00:00",
"ofdName": "ООО Такском",
"bootVersion": "5.10.0",
"ffdVersion": "120",
"fnExecution": "ФН-1.2 исполнение Ин15-3",
"INN": "5258144870 "
}

54
main.py
View File

@@ -1,8 +1,8 @@
# main.py
import sys import sys
import time import time
import logging import logging
import logging.handlers
import os
import schedule import schedule
import config import config
@@ -11,15 +11,53 @@ from sd_api import ServiceDeskClient
from sync_logic import Synchronizer from sync_logic import Synchronizer
def setup_logging(): def setup_logging():
"""Настраивает базовую конфигурацию логирования.""" """
logging.basicConfig( Настраивает продвинутую конфигурацию логирования с ротацией файлов
level=logging.INFO, и разными уровнями для консоли и файла.
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', """
stream=sys.stdout, # Вывод логов в stdout # 1. Устанавливаем корневому логгеру самый низкий уровень (DEBUG).
# Это позволяет хэндлерам самим решать, что пропускать.
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
# 2. Создаем директорию для логов, если она не существует.
try:
if not os.path.exists(config.LOG_PATH):
os.makedirs(config.LOG_PATH)
except OSError as e:
print(f"Ошибка создания директории для логов {config.LOG_PATH}: {e}")
# В случае ошибки выходим, т.к. логирование в файл не будет работать
sys.exit(1)
# 3. Настраиваем форматтер для логов.
log_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
# 4. Настраиваем хэндлер для вывода в консоль (уровень INFO).
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
console_handler.setFormatter(log_formatter)
# 5. Настраиваем хэндлер для записи в файл с ежедневной ротацией (уровень DEBUG).
# Файлы будут вида sync_service.log, sync_service.log.2023-10-27 и т.д.
log_file = os.path.join(config.LOG_PATH, 'sync_service.log')
file_handler = logging.handlers.TimedRotatingFileHandler(
log_file,
when='midnight', # Ротация в полночь
interval=1, # Каждый день
backupCount=7, # Хранить 7 старых файлов
encoding='utf-8'
) )
# Отключаем слишком "болтливые" логи от сторонних библиотек file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(log_formatter)
# 6. Добавляем оба хэндлера к корневому логгеру.
root_logger.addHandler(console_handler)
root_logger.addHandler(file_handler)
# 7. Отключаем слишком "болтливые" логи от сторонних библиотек.
logging.getLogger("requests").setLevel(logging.WARNING) logging.getLogger("requests").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING) logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("schedule").setLevel(logging.INFO)
def job(): def job():
""" """

View File

@@ -2,3 +2,4 @@ python-dotenv
requests requests
schedule schedule
python-dateutil python-dateutil
urllib3

View File

@@ -6,6 +6,8 @@ import json
import os import os
from typing import List, Dict, Any from typing import List, Dict, Any
from datetime import datetime from datetime import datetime
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# Импортируем нашу конфигурацию # Импортируем нашу конфигурацию
import config import config
@@ -19,6 +21,7 @@ class ServiceDeskAPIError(Exception):
class ServiceDeskClient: class ServiceDeskClient:
""" """
Клиент для взаимодействия с REST API ServiceDesk. Клиент для взаимодействия с REST API ServiceDesk.
Оснащен механизмом повторных запросов для повышения отказоустойчивости.
""" """
def __init__(self, access_key: str, base_url: str): def __init__(self, access_key: str, base_url: str):
if not access_key or not base_url: if not access_key or not base_url:
@@ -29,32 +32,55 @@ class ServiceDeskClient:
# Устанавливаем accessKey как параметр по умолчанию для всех запросов # Устанавливаем accessKey как параметр по умолчанию для всех запросов
self.session.params = {'accessKey': access_key} self.session.params = {'accessKey': access_key}
# --- НАСТРОЙКА ЛОГИКИ ПОВТОРНЫХ ЗАПРОСОВ ---
retry_strategy = Retry(
total=3, # Общее количество повторных попыток
backoff_factor=1, # Множитель для задержки (sleep_time = {backoff_factor} * (2 ** ({number of total retries} - 1)))
# Задержки будут примерно 0.5с, 1с, 2с
status_forcelist=[500, 502, 503, 504], # Коды, которые нужно повторять
allowed_methods=["POST", "GET"] # Методы, для которых будет работать retry. API использует POST даже для поиска.
)
# Создаем адаптер с нашей стратегией
adapter = HTTPAdapter(max_retries=retry_strategy)
# "Монтируем" адаптер для всех http и https запросов
self.session.mount("https://", adapter)
self.session.mount("http://", adapter)
log.info("Клиент ServiceDesk инициализирован с логикой повторных запросов (retries).")
def _make_request(self, method: str, url: str, params: Dict = None, json_data: Dict = None) -> Any: def _make_request(self, method: str, url: str, params: Dict = None, json_data: Dict = None) -> Any:
""" """
Внутренний метод для выполнения HTTP-запросов. Внутренний метод для выполнения HTTP-запросов.
Теперь он автоматически использует логику retry, настроенную в __init__.
:param method: HTTP метод ('GET', 'POST', etc.) :param method: HTTP метод ('GET', 'POST', etc.)
:param url: Полный URL для запроса. :param url: Полный URL для запроса.
:param params: Дополнительные параметры URL (кроме accessKey). :param params: Дополнительные параметры URL (кроме accessKey).
:param json_data: Тело запроса в формате JSON для POST/PUT. :param json_data: Тело запроса в формате JSON для POST/PUT.
:return: Ответ сервера в виде JSON. :return: Ответ сервера в виде JSON.
:raises ServiceDeskAPIError: в случае ошибки API или сети. :raises ServiceDeskAPIError: в случае ошибки API или сети ПОСЛЕ всех попыток.
""" """
try: try:
# Теперь этот вызов будет автоматически повторяться в случае сбоев
response = self.session.request(method, url, params=params, json=json_data, timeout=30) response = self.session.request(method, url, params=params, json=json_data, timeout=30)
# Проверяем на ошибки ПОСЛЕ успешного выполнения запроса (или исчерпания попыток)
response.raise_for_status() # Вызовет исключение для кодов 4xx/5xx response.raise_for_status() # Вызовет исключение для кодов 4xx/5xx
# Некоторые ответы могут быть пустыми (например, при успешном редактировании) # Некоторые ответы могут быть пустыми (например, при успешном редактировании - 204)
if response.status_code == 204 or not response.text: # или успешном создании (201)
if response.status_code in [204, 201] or not response.text:
return None return None
return response.json() return response.json()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
# Эта ошибка возникнет, если после всех попыток сервер все равно вернул 4xx или 5xx
error_message = f"HTTP Error: {e.response.status_code} for URL {url}. Response: {e.response.text}" error_message = f"HTTP Error: {e.response.status_code} for URL {url}. Response: {e.response.text}"
log.error(error_message) log.error(error_message)
raise ServiceDeskAPIError(error_message) from e raise ServiceDeskAPIError(error_message) from e
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
# Эта ошибка возникнет, если после всех попыток не удалось подключиться к серверу (например, DNS или таймаут)
error_message = f"Request failed for URL {url}: {e}" error_message = f"Request failed for URL {url}: {e}"
log.error(error_message) log.error(error_message)
raise ServiceDeskAPIError(error_message) from e raise ServiceDeskAPIError(error_message) from e
@@ -73,17 +99,18 @@ class ServiceDeskClient:
} }
frs = self._make_request('POST', config.FIND_FRS_URL, params=params) frs = self._make_request('POST', config.FIND_FRS_URL, params=params)
log.info(f"Получено {len(frs)} записей о ФР.") log.info(f"Получено {len(frs)} записей о ФР.")
return frs # frs может быть None если ответ пустой, вернем пустой список для консистентности
return frs or []
def get_all_workstations(self) -> List[Dict]: def get_all_workstations(self) -> List[Dict]:
"""Получает список всех рабочих станций.""" """Получает список всех рабочих станций."""
log.info("Запрос списка всех рабочих станций из ServiceDesk...") log.info("Запрос списка всех рабочих станций из ServiceDesk...")
params = { params = {
'attrs': 'UUID,owner,AnyDesk,Teamviewer' 'attrs': 'UUID,owner,AnyDesk,Teamviewer,lastModifiedDate'
} }
workstations = self._make_request('POST', config.FIND_WORKSTATIONS_URL, params=params) workstations = self._make_request('POST', config.FIND_WORKSTATIONS_URL, params=params)
log.info(f"Получено {len(workstations)} записей о рабочих станциях.") log.info(f"Получено {len(workstations)} записей о рабочих станциях.")
return workstations return workstations or []
def get_lookup_values(self, metaclass: str) -> List[Dict]: def get_lookup_values(self, metaclass: str) -> List[Dict]:
""" """
@@ -97,7 +124,7 @@ class ServiceDeskClient:
params = {'attrs': 'UUID,title'} params = {'attrs': 'UUID,title'}
lookups = self._make_request('POST', url, params=params) lookups = self._make_request('POST', url, params=params)
log.info(f"Получено {len(lookups)} значений для {metaclass}.") log.info(f"Получено {len(lookups)} значений для {metaclass}.")
return lookups return lookups or []
def update_fr(self, uuid: str, data: Dict) -> None: def update_fr(self, uuid: str, data: Dict) -> None:
""" """
@@ -125,9 +152,6 @@ class ServiceDeskClient:
return return
log.info(f"Обновление объекта ФР с UUID: {uuid}...") log.info(f"Обновление объекта ФР с UUID: {uuid}...")
# Примечание: старый код использовал POST с параметрами для редактирования.
# Если API требует form-encoded data, а не JSON, нужно использовать `data=data` вместо `json=data`.
# Судя по вашему коду, это POST-запрос с параметрами в URL, а не в теле.
url = config.EDIT_FR_URL_TEMPLATE.format(uuid=uuid) url = config.EDIT_FR_URL_TEMPLATE.format(uuid=uuid)
self._make_request('POST', url, params=data) self._make_request('POST', url, params=data)
log.info(f"Объект {uuid} успешно обновлен.") log.info(f"Объект {uuid} успешно обновлен.")

View File

@@ -17,9 +17,7 @@ log = logging.getLogger(__name__)
def _clean_sd_remote_id(raw_id: Optional[str]) -> Optional[str]: def _clean_sd_remote_id(raw_id: Optional[str]) -> Optional[str]:
""" """
Очищает ID удаленного доступа, полученный из ServiceDesk. Очищает ID удаленного доступа, полученный из ServiceDesk..
(Эта функция была ранее в ftp_parser, но перенесена сюда,
так как "грязные" ID приходят из SD).
""" """
if not raw_id or raw_id.lower() == 'none': if not raw_id or raw_id.lower() == 'none':
return None return None
@@ -157,9 +155,10 @@ class Synchronizer:
ws['UUID'], ws['UUID'],
owner['UUID'], owner['UUID'],
_clean_sd_remote_id(ws.get('AnyDesk')), _clean_sd_remote_id(ws.get('AnyDesk')),
_clean_sd_remote_id(ws.get('Teamviewer')) _clean_sd_remote_id(ws.get('Teamviewer')),
ws.get('lastModifiedDate')
)) ))
self.db.bulk_insert('workstations', ['uuid', 'owner_uuid', 'clean_anydesk_id', 'clean_teamviewer_id'], workstations_data) self.db.bulk_insert('workstations', ['uuid', 'owner_uuid', 'clean_anydesk_id', 'clean_teamviewer_id', 'lastModifiedDate'], workstations_data)
# 6. Логика синхронизации # 6. Логика синхронизации
self._update_existing_frs() self._update_existing_frs()
@@ -172,53 +171,111 @@ class Synchronizer:
def _update_existing_frs(self): def _update_existing_frs(self):
"""Находит и обновляет ФР с отличающимися датами.""" """
log.info("Поиск ФР для обновления даты окончания ФН...") Находит и обновляет ФР с отличающимися датами окончания ФН,
воспроизводя проверенную логику в более производительном виде.
"""
log.info("Поиск ФР для обновления...")
# 1. SQL-запрос для поиска расхождений.
# Он объединяет таблицы по serialNumber и выбирает все необходимые поля
# сразу, чтобы избежать вложенных циклов.
query = """ query = """
SELECT SELECT
pos.serialNumber, pos.serialNumber,
pos.dateTime_end AS pos_date, pos.dateTime_end AS pos_date_end,
pos.datetime_reg AS pos_reg_date, pos.datetime_reg AS pos_date_reg,
pos.fn_serial AS pos_fn_serial, pos.fn_serial AS pos_fn_serial,
pos.RNM AS pos_rnm, pos.RNM AS pos_rnm,
pos.bootVersion AS pos_boot_version, pos.bootVersion AS pos_boot_version,
pos.organizationName AS pos_org_name, pos.organizationName AS pos_org_name,
pos.INN AS pos_inn, pos.INN AS pos_inn,
sd.UUID AS sd_uuid, sd.UUID AS sd_uuid,
sd.dateTime_end AS sd_date sd.dateTime_end AS sd_date_end
FROM pos_fiscals pos FROM pos_fiscals pos
JOIN sd_fiscals sd ON pos.serialNumber = sd.serialNumber JOIN sd_fiscals sd ON pos.serialNumber = sd.serialNumber
WHERE pos.dateTime_end != sd.dateTime_end
""" """
# Дополнительное условие по дате, как в старом коде, можно добавить сюда, если нужно
# WHERE date(pos.dateTime_end) > date(sd.dateTime_end, '-65 day') AND pos.dateTime_end != sd.dateTime_end
records_to_update = self.db._execute_query(query, fetch='all') records_to_check = self.db._execute_query(query, fetch='all')
log.info(f"Найдено {len(records_to_update)} ФР для обновления.") update_counter = 0
for rec in records_to_update: for rec in records_to_check:
# Проверка, что дата действительно новее (избегаем "старых" данных с FTP) try:
# Это простая проверка, можно усложнить, если нужно # 2. Приводим даты к одному "знаменателю" - объектам datetime
if parser.parse(rec['pos_date']) < parser.parse(rec['sd_date']): # dateutil.parser отлично справляется с разными форматами
log.warning(f"Пропуск обновления для S/N {rec['serialNumber']}: дата с FTP ({rec['pos_date']}) " # Проверяем, что обе даты существуют, прежде чем их парсить
f"старше, чем в SD ({rec['sd_date']}).") if not rec['pos_date_end'] or not rec['sd_date_end']:
log.warning(f"Пропуск сравнения для S/N {rec['serialNumber']}: одна из дат отсутствует.")
continue continue
legal_name = f"{rec['pos_org_name']} ИНН:{rec['pos_inn']}" if rec['pos_org_name'] and rec['pos_inn'] else "ЗАКОНЧИЛСЯ ФН" pos_date = parser.parse(rec['pos_date_end'])
sd_date = parser.parse(rec['sd_date_end'])
# 3. Сравниваем даты. Если они различаются, готовим обновление.
# Проверяем на неравенство. Величина различия не важна.
if pos_date != sd_date:
log.info(f"Найдено расхождение в дате для S/N {rec['serialNumber']} (UUID: {rec['sd_uuid']}). "
f"FTP: {pos_date}, SD: {sd_date}. Подготовка к обновлению.")
# 4. Формируем данные для обновления
# 5. Проверяем, закончился ли ФН
if rec['pos_rnm'] == '0000000000000000':
legal_name = 'ЗАКОНЧИЛСЯ ФН'
else:
legal_name = f"{rec['pos_org_name']} ИНН:{rec['pos_inn']}" if rec['pos_org_name'] and rec['pos_inn'] else rec['pos_org_name']
# Форматируем даты для API ServiceDesk
formatted_expire_date = pos_date.strftime('%Y.%m.%d %H:%M:%S')
# Убеждаемся, что дата регистрации тоже есть, прежде чем форматировать
formatted_reg_date = parser.parse(rec['pos_date_reg']).strftime('%Y.%m.%d %H:%M:%S') if rec['pos_date_reg'] else None
params_to_update = { params_to_update = {
'FNNumber': rec['pos_fn_serial'], 'FNNumber': rec['pos_fn_serial'],
'FNExpireDate': parser.parse(rec['pos_date']).strftime('%Y.%m.%d %H:%M:%S'), 'FNExpireDate': formatted_expire_date,
'KKTRegDate': parser.parse(rec['pos_reg_date']).strftime('%Y.%m.%d %H:%M:%S'),
'LegalName': legal_name, 'LegalName': legal_name,
'RNKKT': rec['pos_rnm'], 'RNKKT': rec['pos_rnm'],
'FRDownloader': rec['pos_boot_version'] 'FRDownloader': rec['pos_boot_version'],
'KKTRegDate': formatted_reg_date
} }
# Удаляем ключи с None-значениями, чтобы не отправлять их в API, если они не обязательны
params_to_update = {k: v for k, v in params_to_update.items() if v is not None}
try: try:
self.sd.update_fr(rec['sd_uuid'], params_to_update) self.sd.update_fr(rec['sd_uuid'], params_to_update)
update_counter += 1
except ServiceDeskAPIError as e: except ServiceDeskAPIError as e:
log.error(f"Не удалось обновить ФР с UUID {rec['sd_uuid']}: {e}") log.error(f"Не удалось обновить ФР с UUID {rec['sd_uuid']}: {e}")
except (parser.ParserError, TypeError) as e:
log.error(f"Ошибка парсинга даты для S/N {rec['serialNumber']}. "
f"FTP_date='{rec['pos_date_end']}', SD_date='{rec['sd_date_end']}'. Ошибка: {e}")
continue
log.info(f"Проверка обновлений завершена. Обновлено записей: {update_counter}.")
def _find_lookup_uuid_by_substring(self, lookup_type: str, substring: str) -> Optional[str]:
"""
Ищет UUID в кэше справочников по подстроке в ключе (title).
Например, ищет '13' в ключе '13 мес.'.
:param lookup_type: Тип справочника ('SrokiFN', 'FFD', etc.).
:param substring: Подстрока для поиска (например, '13').
:return: UUID или None, если не найдено.
"""
if not substring or lookup_type not in self.lookup_cache:
return None
# Проходим по всем парам (title, uuid) в нужном справочнике
for title, uuid in self.lookup_cache[lookup_type].items():
# Ищем точное вхождение подстроки, окруженное не-цифрами или границами строки,
# чтобы '15' не совпало с '150'.
if re.search(r'\b' + re.escape(substring) + r'\b', title):
return uuid
return None
def _create_new_frs(self): def _create_new_frs(self):
"""Находит, определяет владельца и создает новые ФР.""" """Находит, определяет владельца и создает новые ФР."""
log.info("Поиск новых ФР для добавления в ServiceDesk...") log.info("Поиск новых ФР для добавления в ServiceDesk...")
@@ -238,10 +295,14 @@ class Synchronizer:
model_uuid = self.lookup_cache.get('ModeliFR', {}).get(fr['modelName']) model_uuid = self.lookup_cache.get('ModeliFR', {}).get(fr['modelName'])
srok_fn_str = _extract_srok_fn_from_execution(fr['fnExecution']) srok_fn_str = _extract_srok_fn_from_execution(fr['fnExecution'])
srok_fn_uuid = self.lookup_cache.get('SrokiFN', {}).get(srok_fn_str) if srok_fn_str else None if not srok_fn_str:
srok_fn_str = '13'
log.warning(f"Для S/N {fr['serialNumber']} не удалось определить срок ФН из fnExecution. "
f"Установлен срок по умолчанию: {srok_fn_str} мес.")
srok_fn_uuid = self._find_lookup_uuid_by_substring('SrokiFN', srok_fn_str)
ffd_version_str = _map_ffd_version(fr['ffdVersion']) ffd_version_str = _map_ffd_version(fr['ffdVersion'])
ffd_uuid = self.lookup_cache.get('FFD', {}).get(ffd_version_str) if ffd_version_str else None ffd_uuid = self._find_lookup_uuid_by_substring('FFD', ffd_version_str)
if not all([model_uuid, srok_fn_uuid, ffd_uuid]): if not all([model_uuid, srok_fn_uuid, ffd_uuid]):
log.error(f"Не удалось создать ФР с S/N {fr['serialNumber']}: не найдены UUID для справочников. " log.error(f"Не удалось создать ФР с S/N {fr['serialNumber']}: не найдены UUID для справочников. "
@@ -270,20 +331,45 @@ class Synchronizer:
log.error(f"Не удалось создать ФР с S/N {fr['serialNumber']}: {e}") log.error(f"Не удалось создать ФР с S/N {fr['serialNumber']}: {e}")
def _find_owner_uuid(self, anydesk_id: Optional[str], teamviewer_id: Optional[str]) -> str: def _find_owner_uuid(self, anydesk_id: Optional[str], teamviewer_id: Optional[str]) -> str:
"""Ищет владельца по ID. Возвращает UUID владельца или UUID по умолчанию.""" """
Ищет владельца по ID. При наличии нескольких совпадений для одного ID,
выбирает запись с самой свежей датой lastModifiedDate.
Возвращает UUID владельца или UUID по умолчанию.
"""
def query_owner(id_value, id_type_column): def query_best_owner(id_value: Optional[str], id_type_column: str) -> Optional[str]:
"""
Запрашивает всех владельцев по ID и возвращает UUID самого "свежего".
"""
if not id_value: if not id_value:
return None return None
query = f"SELECT owner_uuid FROM workstations WHERE {id_type_column} = ?"
result = self.db._execute_query(query, (id_value,), fetch='one')
return result['owner_uuid'] if result else None
owner_by_anydesk = query_owner(anydesk_id, 'clean_anydesk_id') # Запрашиваем всех кандидатов, сортируя по дате в порядке убывания (самые свежие - первые)
owner_by_teamviewer = query_owner(teamviewer_id, 'clean_teamviewer_id') query = f"""
SELECT owner_uuid, lastModifiedDate
FROM workstations
WHERE {id_type_column} = ?
ORDER BY lastModifiedDate DESC
"""
results = self.db._execute_query(query, (id_value,), fetch='all')
if not results:
return None
if len(results) > 1:
log.warning(f"Найдено несколько ({len(results)}) рабочих станций с {id_type_column} = {id_value}. "
f"Выбрана самая свежая запись от {results[0]['lastModifiedDate']}.")
# Так как мы отсортировали по DESC, первая запись и есть самая свежая
return results[0]['owner_uuid']
owner_by_anydesk = query_best_owner(anydesk_id, 'clean_anydesk_id')
owner_by_teamviewer = query_best_owner(teamviewer_id, 'clean_teamviewer_id')
# Логика выбора остается прежней, но теперь она работает с "лучшими" кандидатами
if owner_by_anydesk and owner_by_teamviewer and owner_by_anydesk == owner_by_teamviewer: if owner_by_anydesk and owner_by_teamviewer and owner_by_anydesk == owner_by_teamviewer:
log.info(f"Владелец {owner_by_anydesk} найден по обоим ID: Anydesk={anydesk_id}, TV={teamviewer_id}.") log.info(f"Владелец {owner_by_anydesk} уверенно найден по обоим ID: Anydesk={anydesk_id}, TV={teamviewer_id}.")
return owner_by_anydesk return owner_by_anydesk
if owner_by_anydesk: if owner_by_anydesk:
@@ -297,3 +383,4 @@ class Synchronizer:
log.warning(f"Владелец не найден для Anydesk={anydesk_id}, TV={teamviewer_id}. " log.warning(f"Владелец не найден для Anydesk={anydesk_id}, TV={teamviewer_id}. "
f"Будет назначен владелец по умолчанию: {config.DEFAULT_OWNER_UUID}") f"Будет назначен владелец по умолчанию: {config.DEFAULT_OWNER_UUID}")
return config.DEFAULT_OWNER_UUID return config.DEFAULT_OWNER_UUID