Compare commits

...

13 Commits

Author SHA1 Message Date
c677e4f8af full peresobral 2025-07-21 16:59:26 +03:00
a6e21a7e59 Merge branch 'rebuild' of https://github.com/serty2005/MHservice into rebuild 2024-05-10 22:11:16 +03:00
1e950443ad rebuild 0.0.2 2024-05-10 22:10:38 +03:00
serty2005
b708d50f53 Delete .env 2024-05-08 23:33:06 +03:00
0552be01bd rebuild 0.0.2 2024-05-05 17:40:48 +03:00
538db4d006 0.0.1 2024-05-05 01:25:06 +03:00
add2b70b5a rebuild for sqlalchemy 2024-05-05 01:24:39 +03:00
656b6f61bb fix 0.1.0 2024-05-02 07:11:26 +03:00
serty2005
8d7eaac569 Merge pull request #4 from serty2005/prepare
prepare dockerfile 0.1.0
2024-04-24 16:30:50 +03:00
2eb133f071 prepare dockerfile 0.1.0 2024-04-24 16:28:55 +03:00
serty2005
7249468bf6 Merge pull request #3 from serty2005/prepare
workающий release 0.3.0
2024-04-24 16:06:53 +03:00
serty2005
c29a907de9 Merge pull request #2 from serty2005/prepare
0.0.2
2024-04-22 06:58:09 +03:00
serty2005
9cf312c975 Merge pull request #1 from serty2005/prepare
first build
2024-04-22 04:48:13 +03:00
23 changed files with 1042 additions and 166 deletions

7
.gitignore vendored
View File

@@ -1,2 +1,5 @@
/.venv
.env
.venv
.env
docker-compose.yml
*.db
__*

149
app.py
View File

@@ -1,149 +0,0 @@
import os
import json
import sqlite3
import time
import requests
import sys
from dateutil import parser
import schedule
def post(url, params):
response = requests.post(url, params=params)
if response.status_code == 201:
return sys.stdout.write("Успешно добавлено\n")
elif response.status_code == 200:
return response.text
else:
sys.stderr.write("Ошибка при загрузке данных: {}\n".format(response.status_code))
return None
def create_table(type):
#Создаём пустую таблицу
db_path = os.getenv("BDPATH")
db_file = "fiscals.db"
table_name = type
conn = sqlite3.connect(db_path + db_file)
c = conn.cursor()
c.execute("""
CREATE TABLE IF NOT EXISTS %s (
serialNumber TEXT PRIMARY KEY,
modelName TEXT,
RNM TEXT,
organizationName TEXT,
fn_serial TEXT,
datetime_reg TEXT,
dateTime_end TEXT,
ofdName TEXT,
bootVersion TEXT,
ffdVersion TEXT,
fnExecution TEXT,
INN TEXT,
UUID TEXT,
owner_uuid TEXT
)""" % table_name)
conn.commit()
conn.close()
def importFromJSON(file_path):
with open(file_path, 'r', encoding='utf-8') as json_file:
data = json.load(json_file)
path = os.getenv("BDPATH") + 'fiscals.db'
conn = sqlite3.connect(path)
c = conn.cursor()
table_exists = c.execute('''SELECT name FROM sqlite_master WHERE type='table' AND name='pos_fiscals' ''').fetchone()
if not table_exists:
create_table('pos_fiscals')
c.execute('''INSERT OR REPLACE INTO pos_fiscals
(modelName, serialNumber, RNM, organizationName, fn_serial, datetime_reg,
dateTime_end, ofdName, bootVersion, ffdVersion, fnExecution, INN)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
(data['modelName'], data['serialNumber'], data['RNM'], data['organizationName'],
data['fn_serial'], data['datetime_reg'], data['dateTime_end'], data['ofdName'],
data['bootVersion'], data['ffdVersion'], data['fnExecution'], data['INN']))
conn.commit()
conn.close()
def process_json_files(directory):
for filename in os.listdir(directory):
if filename.endswith('.json'):
importFromJSON(os.path.join(directory, filename))
def importFromServiceDesk(sd_data):
conn = sqlite3.connect(os.getenv("BDPATH") + 'fiscals.db')
parsed=json.loads(sd_data)
c = conn.cursor()
for data in parsed:
owner_uuid = data['owner']['UUID'] if data.get('owner') and data['owner'].get('UUID') else None
modelName = data['ModelKKT']['title']
ofdName = data['OFDName']['title'] if data.get('OFDName') else None
ffdVersion = data['FFD']['title'] if data.get('FFD') else None
c.execute('''INSERT OR REPLACE INTO sd_fiscals
(modelName, serialNumber, RNM, organizationName, fn_serial, datetime_reg,
dateTime_end, ofdName, bootVersion, ffdVersion, owner_uuid, UUID)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
(modelName, data['FRSerialNumber'], data['RNKKT'], data['LegalName'],
data['FNNumber'], data['KKTRegDate'], data['FNExpireDate'], ofdName,
data['FRDownloader'], ffdVersion, owner_uuid, data['UUID']))
conn.commit()
conn.close()
def update_sd_table():
url = 'https://myhoreca.itsm365.com/sd/services/rest/find/objectBase$FR'
params = {'accessKey': os.getenv('SDKEY'), 'attrs': 'UUID,FRSerialNumber,RNKKT,KKTRegDate,FNExpireDate,FNNumber,owner,FRDownloader,LegalName,OFDName,ModelKKT,FFD'}
response = post(url, params)
if response:
importFromServiceDesk(response)
sys.stdout.write("База из SD обновлена успешно.\n")
else:
sys.stderr.write("Ошибка при получении данных: {}\n".format(response.status_code))
def compare_and_update():
conn_sd = sqlite3.connect(os.getenv("BDPATH") + 'fiscals.db')
conn_pos = sqlite3.connect(os.getenv("BDPATH") + 'fiscals.db')
c_pos = conn_pos.cursor()
c_sd = conn_sd.cursor()
c_sd.execute('''SELECT modelName, serialNumber, RNM, organizationName, fn_serial, datetime_reg,
dateTime_end, ofdName, bootVersion, ffdVersion, fnExecution, owner_uuid, UUID
FROM sd_fiscals''')
sd_data = c_sd.fetchall()
c_pos.execute('''SELECT modelName, serialNumber, RNM, organizationName, fn_serial, datetime_reg,
dateTime_end, ofdName, bootVersion, ffdVersion, fnExecution, INN
FROM pos_fiscals''')
pos_data = c_pos.fetchall()
for sd_entry in sd_data:
for pos_entry in pos_data:
if sd_entry[1] == pos_entry[1]:
sd_date = parser.parse(sd_entry[6])
pos_date = parser.parse(pos_entry[6])
if sd_date != pos_date:
sys.stdout.write(f"Объект с UUID {sd_entry[12]} будет изменен.\n")
formatted_date = pos_date.strftime('%Y.%m.%d %H:%M:%S')
legalName = pos_entry[3] + ' ' + 'ИНН:' + pos_entry[11]
edit_url = f'https://myhoreca.itsm365.com/sd/services/rest/edit/{sd_entry[12]}'
params = {'accessKey': os.getenv('SDKEY'), 'FNNumber': pos_entry[4], 'FNExpireDate': formatted_date, 'LegalName': legalName, 'RNKKT': pos_entry[2], 'FRDownloader': pos_entry[8]}
post(edit_url, params)
conn_pos.close()
conn_sd.close()
def run_tasks():
schedule.every(15).seconds.do(process_json_files, os.getenv('JSONPATH'))
schedule.every(15).seconds.do(update_sd_table)
schedule.every(15).seconds.do(compare_and_update)
while True:
schedule.run_pending()
time.sleep(1)
if __name__ == '__main__':
create_table('pos_fiscals')
create_table('sd_fiscals')
run_tasks()

34
config.py Normal file
View File

@@ -0,0 +1,34 @@
import os
from dotenv import load_dotenv
# Загружаем переменные из .env файла в окружение
load_dotenv()
# --- ServiceDesk API Configuration ---
SD_ACCESS_KEY = os.getenv("SDKEY")
SD_BASE_URL = os.getenv("SD_BASE_URL")
if not all([SD_ACCESS_KEY, SD_BASE_URL]):
raise ValueError("Необходимо задать переменные окружения SDKEY и SD_BASE_URL")
# --- Paths ---
DB_PATH = os.getenv("BDPATH", ".") # По умолчанию - текущая папка
DB_NAME = "fiscals.db"
DB_FULL_PATH = os.path.join(DB_PATH, DB_NAME)
JSON_FILES_PATH = os.getenv("JSONPATH")
if not JSON_FILES_PATH:
raise ValueError("Необходимо задать переменную окружения JSONPATH")
# --- Sync Logic Configuration ---
DEFAULT_OWNER_UUID = os.getenv("DEFAULT_OWNER_UUID")
if not DEFAULT_OWNER_UUID:
raise ValueError("Необходимо задать UUID владельца по умолчанию в DEFAULT_OWNER_UUID")
# --- URLs for SD API ---
# Используем f-строки для удобного формирования URL
FIND_FRS_URL = f"{SD_BASE_URL}/find/objectBase$FR"
FIND_WORKSTATIONS_URL = f"{SD_BASE_URL}/find/objectBase$Workstation"
# URL для создания будет формироваться динамически, но базовый шаблон такой:
CREATE_FR_URL = f"{SD_BASE_URL}/create-m2m/objectBase$FR"
# URL для редактирования будет принимать UUID объекта
EDIT_FR_URL_TEMPLATE = f"{SD_BASE_URL}/edit/{{uuid}}"

153
database.py Normal file
View File

@@ -0,0 +1,153 @@
# database.py
import sqlite3
import logging
# Настройка логирования для модуля БД
log = logging.getLogger(__name__)
class DatabaseManager:
"""
Класс для управления всеми операциями с базой данных SQLite.
Обеспечивает централизованное подключение и выполнение запросов.
"""
def __init__(self, db_path):
self.db_path = db_path
self.conn = None
def __enter__(self):
"""Открывает соединение с БД при входе в контекстный менеджер."""
try:
self.conn = sqlite3.connect(self.db_path)
# Устанавливаем row_factory для получения результатов в виде словарей
self.conn.row_factory = sqlite3.Row
log.info(f"Соединение с БД {self.db_path} установлено.")
return self
except sqlite3.Error as e:
log.error(f"Ошибка подключения к БД: {e}")
raise
def __exit__(self, exc_type, exc_val, exc_tb):
"""Закрывает соединение при выходе из контекстного менеджера."""
if self.conn:
self.conn.close()
log.info("Соединение с БД закрыто.")
def _execute_query(self, query, params=(), fetch=None):
"""
Приватный метод для выполнения SQL-запросов.
Использует параметризацию для предотвращения SQL-инъекций.
:param query: SQL-запрос.
:param params: Кортеж с параметрами для запроса.
:param fetch: 'one', 'all' или None для коммита.
"""
try:
cursor = self.conn.cursor()
cursor.execute(query, params)
if fetch == 'one':
return cursor.fetchone()
elif fetch == 'all':
return cursor.fetchall()
else:
self.conn.commit()
return cursor.lastrowid
except sqlite3.Error as e:
log.error(f"Ошибка выполнения запроса: {query} | {e}")
self.conn.rollback() # Откатываем транзакцию в случае ошибки
raise
def setup_tables(self):
"""Создает все необходимые таблицы, если они не существуют."""
log.info("Проверка и создание таблиц в БД...")
# Таблица для данных с FTP
self._execute_query("""
CREATE TABLE IF NOT EXISTS pos_fiscals (
serialNumber TEXT PRIMARY KEY,
modelName TEXT,
RNM TEXT,
organizationName TEXT,
fn_serial TEXT,
datetime_reg TEXT,
dateTime_end TEXT,
ofdName TEXT,
bootVersion TEXT,
ffdVersion TEXT,
fnExecution TEXT,
INN TEXT,
anydesk_id TEXT,
teamviewer_id TEXT,
lastModifiedDate TEXT
)""")
# Таблица для данных из ServiceDesk
self._execute_query("""
CREATE TABLE IF NOT EXISTS sd_fiscals (
UUID TEXT PRIMARY KEY,
serialNumber TEXT UNIQUE,
modelName TEXT,
RNM TEXT,
organizationName TEXT,
fn_serial TEXT,
datetime_reg TEXT,
dateTime_end TEXT,
ofdName TEXT,
bootVersion TEXT,
ffdVersion TEXT,
owner_uuid TEXT,
lastModifiedDate TEXT
)""")
# Новая таблица для рабочих станций
self._execute_query("""
CREATE TABLE IF NOT EXISTS workstations (
uuid TEXT PRIMARY KEY,
owner_uuid TEXT,
clean_anydesk_id TEXT,
clean_teamviewer_id TEXT
)""")
# Новая таблица для кэширования UUID справочников
self._execute_query("""
CREATE TABLE IF NOT EXISTS sd_lookups (
lookup_type TEXT, -- 'ModelKKT', 'FFD', etc.
title TEXT, -- 'АТОЛ 25Ф', '1.2', etc.
uuid TEXT,
PRIMARY KEY (lookup_type, title)
)""")
log.info("Все таблицы успешно созданы/проверены.")
def clear_tables(self, table_names: list):
"""Очищает указанные таблицы перед каждым циклом синхронизации."""
log.info(f"Очистка таблиц: {', '.join(table_names)}")
for table in table_names:
# Проверяем, что имя таблицы "безопасное"
if table.isalnum():
self._execute_query(f"DELETE FROM {table}")
else:
log.warning(f"Попытка очистить таблицу с некорректным именем: {table}")
def bulk_insert(self, table_name: str, columns: list, data: list):
"""
Выполняет массовую вставку данных в таблицу.
:param table_name: Имя таблицы.
:param columns: Список названий колонок.
:param data: Список кортежей с данными для вставки.
"""
if not data:
return
placeholders = ', '.join(['?'] * len(columns))
cols = ', '.join(columns)
query = f"INSERT OR REPLACE INTO {table_name} ({cols}) VALUES ({placeholders})"
try:
cursor = self.conn.cursor()
cursor.executemany(query, data)
self.conn.commit()
log.info(f"Вставлено {len(data)} записей в таблицу {table_name}.")
except sqlite3.Error as e:
log.error(f"Ошибка массовой вставки в {table_name}: {e}")
self.conn.rollback()
raise

View File

@@ -1,15 +0,0 @@
FROM python:3.11.9-slim
WORKDIR /opt/app
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git \
tzdata \
&& rm -rf /var/lib/apt/lists/*
RUN git clone https://github.com/serty2005/MHservice.git .
RUN pip install -r requirements.txt
CMD ["python", "/opt/app/app.py"]

23
files/00105707796831.json Normal file
View File

@@ -0,0 +1,23 @@
{
"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"
}

23
files/00106126844935.json Normal file
View File

@@ -0,0 +1,23 @@
{
"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"
}

23
files/00106202123911.json Normal file
View File

@@ -0,0 +1,23 @@
{
"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"
}

23
files/00106302050149.json Normal file
View File

@@ -0,0 +1,23 @@
{
"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"
}

23
files/00106309062146.json Normal file
View File

@@ -0,0 +1,23 @@
{
"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"
}

23
files/00106309239401.json Normal file
View File

@@ -0,0 +1,23 @@
{
"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"
}

23
files/00106309300916.json Normal file
View File

@@ -0,0 +1,23 @@
{
"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"
}

23
files/00106309330871.json Normal file
View File

@@ -0,0 +1,23 @@
{
"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"
}

23
files/00106905442664.json Normal file
View File

@@ -0,0 +1,23 @@
{
"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"
}

23
files/00108100739722.json Normal file
View File

@@ -0,0 +1,23 @@
{
"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"
}

23
files/00108129540393.json Normal file
View File

@@ -0,0 +1,23 @@
{
"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"
}

23
files/00108722684571.json Normal file
View File

@@ -0,0 +1,23 @@
{
"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"
}

23
files/00109522991414.json Normal file
View File

@@ -0,0 +1,23 @@
{
"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"
}

66
ftp_parser.py Normal file
View File

@@ -0,0 +1,66 @@
# ftp_parser.py
import os
import json
import logging
from typing import List, Dict
log = logging.getLogger(__name__)
def process_json_files(directory: str) -> List[Dict]:
"""
Сканирует директорию, читает все .json файлы и извлекает из них данные
о фискальных регистраторах в стандартизированном формате.
:param directory: Путь к директории с JSON файлами.
:return: Список словарей, где каждый словарь представляет один ФР.
"""
if not os.path.isdir(directory):
log.error(f"Указанный путь не является директорией: {directory}")
return []
all_fr_data = []
log.info(f"Начинаю обработку JSON файлов из директории: {directory}")
for filename in os.listdir(directory):
if filename.lower().endswith('.json'):
file_path = os.path.join(directory, filename)
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
if 'serialNumber' not in data or not data['serialNumber']:
log.warning(f"Пропущен файл {filename}: отсутствует serialNumber.")
continue
# Поля teamviewer_id могут быть написаны по-разному,
# например teamviever_id. Обработаем это.
teamviewer_id = data.get('teamviewer_id') or data.get('teamviever_id')
fr_record = {
'serialNumber': data.get('serialNumber'),
'modelName': data.get('modelName'),
'RNM': data.get('RNM') or '0000000000000000',
'organizationName': data.get('organizationName'),
'fn_serial': data.get('fn_serial') or '0000000000000000',
'datetime_reg': data.get('datetime_reg'),
'dateTime_end': data.get('dateTime_end'),
'ofdName': data.get('ofdName'),
'bootVersion': data.get('bootVersion'),
'ffdVersion': data.get('ffdVersion'), # В примере '120', а не '1.2'. Нужно будет уточнить.
'fnExecution': data.get('fnExecution'),
'INN': data.get('INN'),
'anydesk_id': data.get('anydesk_id'), # Приходят чистые
'teamviewer_id': teamviewer_id, # Приходят чистые
'lastModifiedDate': data.get('current_time')
}
all_fr_data.append(fr_record)
log.debug(f"Успешно обработан файл {filename}. S/N: {fr_record['serialNumber']}")
except json.JSONDecodeError:
log.error(f"Ошибка декодирования JSON в файле: {filename}")
except Exception as e:
log.error(f"Неизвестная ошибка при обработке файла {filename}: {e}")
log.info(f"Обработка JSON файлов завершена. Получено {len(all_fr_data)} записей.")
return all_fr_data

65
main.py Normal file
View File

@@ -0,0 +1,65 @@
# main.py
import sys
import time
import logging
import schedule
import config
from database import DatabaseManager
from sd_api import ServiceDeskClient
from sync_logic import Synchronizer
def setup_logging():
"""Настраивает базовую конфигурацию логирования."""
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
stream=sys.stdout, # Вывод логов в stdout
)
# Отключаем слишком "болтливые" логи от сторонних библиотек
logging.getLogger("requests").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
def job():
"""
Основная задача, которую будет выполнять планировщик.
"""
log = logging.getLogger(__name__)
log.info("Запуск задачи синхронизации...")
try:
# Инициализация всех компонентов
db_manager = DatabaseManager(config.DB_FULL_PATH)
sd_client = ServiceDeskClient(config.SD_ACCESS_KEY, config.SD_BASE_URL)
# Контекстный менеджер для БД гарантирует открытие/закрытие соединения
with db_manager as db:
# Перед первым запуском создаем таблицы, если их нет
db.setup_tables()
synchronizer = Synchronizer(db, sd_client)
synchronizer.run_full_sync()
except Exception as e:
log.critical(f"Произошла непредвиденная ошибка на верхнем уровне: {e}", exc_info=True)
if __name__ == '__main__':
setup_logging()
main_log = logging.getLogger(__name__)
# --- Первоначальный запуск ---
# Можно запустить один раз при старте, чтобы не ждать первого интервала
main_log.info("Первый запуск сервиса...")
job()
# --- Настройка расписания ---
# В вашем коде было 3 минуты, можно настроить как угодно
sync_interval_minutes = 15
main_log.info(f"Сервис запущен. Синхронизация будет выполняться каждые {sync_interval_minutes} минут.")
schedule.every(sync_interval_minutes).minutes.do(job)
while True:
schedule.run_pending()
time.sleep(1)

Binary file not shown.

121
sd_api.py Normal file
View File

@@ -0,0 +1,121 @@
# sd_api.py
import requests
import logging
import json
from typing import List, Dict, Any
# Импортируем нашу конфигурацию
import config
log = logging.getLogger(__name__)
class ServiceDeskAPIError(Exception):
"""Кастомное исключение для ошибок API ServiceDesk."""
pass
class ServiceDeskClient:
"""
Клиент для взаимодействия с REST API ServiceDesk.
"""
def __init__(self, access_key: str, base_url: str):
if not access_key or not base_url:
raise ValueError("Access key and base URL cannot be empty.")
self.base_url = base_url
self.session = requests.Session()
# Устанавливаем accessKey как параметр по умолчанию для всех запросов
self.session.params = {'accessKey': access_key}
def _make_request(self, method: str, url: str, params: Dict = None, json_data: Dict = None) -> Any:
"""
Внутренний метод для выполнения HTTP-запросов.
:param method: HTTP метод ('GET', 'POST', etc.)
:param url: Полный URL для запроса.
:param params: Дополнительные параметры URL (кроме accessKey).
:param json_data: Тело запроса в формате JSON для POST/PUT.
:return: Ответ сервера в виде JSON.
:raises ServiceDeskAPIError: в случае ошибки API или сети.
"""
try:
response = self.session.request(method, url, params=params, json=json_data, timeout=30)
response.raise_for_status() # Вызовет исключение для кодов 4xx/5xx
# Некоторые ответы могут быть пустыми (например, при успешном редактировании)
if response.status_code == 204 or not response.text:
return None
return response.json()
except requests.exceptions.HTTPError as e:
error_message = f"HTTP Error: {e.response.status_code} for URL {url}. Response: {e.response.text}"
log.error(error_message)
raise ServiceDeskAPIError(error_message) from e
except requests.exceptions.RequestException as e:
error_message = f"Request failed for URL {url}: {e}"
log.error(error_message)
raise ServiceDeskAPIError(error_message) from e
def get_all_frs(self) -> List[Dict]:
"""Получает список всех фискальных регистраторов."""
log.info("Запрос списка всех ФР из ServiceDesk...")
params = {
'attrs': 'UUID,FRSerialNumber,RNKKT,KKTRegDate,FNExpireDate,FNNumber,owner,FRDownloader,LegalName,OFDName,ModelKKT,FFD,lastModifiedDate'
}
frs = self._make_request('POST', config.FIND_FRS_URL, params=params)
log.info(f"Получено {len(frs)} записей о ФР.")
return frs
def get_all_workstations(self) -> List[Dict]:
"""Получает список всех рабочих станций."""
log.info("Запрос списка всех рабочих станций из ServiceDesk...")
params = {
'attrs': 'UUID,owner,AnyDesk,Teamviewer'
}
workstations = self._make_request('POST', config.FIND_WORKSTATIONS_URL, params=params)
log.info(f"Получено {len(workstations)} записей о рабочих станциях.")
return workstations
def get_lookup_values(self, metaclass: str) -> List[Dict]:
"""
Получает значения из справочника по его metaClass.
:param metaclass: metaClass справочника (e.g., 'ModeliFR', 'FFD').
:return: Список словарей с 'UUID' и 'title'.
"""
log.info(f"Запрос значений справочника для metaClass: {metaclass}...")
url = f"{self.base_url}/find/{metaclass}"
params = {'attrs': 'UUID,title'}
lookups = self._make_request('POST', url, params=params)
log.info(f"Получено {len(lookups)} значений для {metaclass}.")
return lookups
def update_fr(self, uuid: str, data: Dict) -> None:
"""
Обновляет существующий фискальный регистратор.
:param uuid: UUID объекта для редактирования.
:param data: Словарь с полями для обновления.
"""
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)
self._make_request('POST', url, params=data)
log.info(f"Объект {uuid} успешно обновлен.")
def create_fr(self, data: Dict) -> Dict:
"""
Создает новый фискальный регистратор.
:param data: Словарь с данными для создания объекта (тело запроса).
:return: JSON-ответ от сервера, содержащий UUID нового объекта.
"""
log.info(f"Создание нового ФР с серийным номером: {data.get('FRSerialNumber')}...")
# Параметр для получения UUID в ответе
params = {'attrs': 'UUID'}
response = self._make_request('POST', config.CREATE_FR_URL, params=params, json_data=data)
log.info(f"ФР успешно создан. Ответ сервера: {response}")
return response

299
sync_logic.py Normal file
View File

@@ -0,0 +1,299 @@
# sync_logic.py
import re
import logging
from typing import Dict, List, Optional, Any
from datetime import datetime
from dateutil import parser
# Импортируем наши модули
import config
from database import DatabaseManager
from sd_api import ServiceDeskClient, ServiceDeskAPIError
import ftp_parser
log = logging.getLogger(__name__)
def _clean_sd_remote_id(raw_id: Optional[str]) -> Optional[str]:
"""
Очищает ID удаленного доступа, полученный из ServiceDesk.
(Эта функция была ранее в ftp_parser, но перенесена сюда,
так как "грязные" ID приходят из SD).
"""
if not raw_id or raw_id.lower() == 'none':
return None
no_spaces_id = re.sub(r'\s+', '', raw_id)
match = re.search(r'\d+', no_spaces_id)
return match.group(0) if match else None
def _extract_srok_fn_from_execution(fn_execution: Optional[str]) -> Optional[str]:
"""Извлекает срок ФН (13, 15, 36) из строки fnExecution."""
if not fn_execution:
return None
# Ищем числа 13, 15 или 36 в строке
match = re.search(r'(13|15|36)', fn_execution)
return match.group(1) if match else None
def _map_ffd_version(ffd_from_ftp: Optional[str]) -> Optional[str]:
"""Сопоставляет версию ФФД из FTP ('120') с версией в SD ('1.2')."""
if ffd_from_ftp == '120':
return '1.2'
if ffd_from_ftp == '105':
return '1.05'
# Можно добавить другие сопоставления, если появятся
return ffd_from_ftp # Возвращаем как есть, если нет сопоставления
class Synchronizer:
"""
Класс, инкапсулирующий всю логику синхронизации данных.
"""
def __init__(self, db_manager: DatabaseManager, sd_client: ServiceDeskClient):
self.db = db_manager
self.sd = sd_client
self.lookup_cache: Dict[str, Dict[str, str]] = {}
def _prepare_data_for_db(self, fr_list: List[Dict], source: str) -> List[tuple]:
"""
Преобразует список словарей в список кортежей для массовой вставки в БД.
Также логирует отсутствующие, но важные поля.
"""
prepared_data = []
if source == 'pos':
# Ожидаемые колонки для pos_fiscals
columns = ('serialNumber', 'modelName', 'RNM', 'organizationName', 'fn_serial',
'datetime_reg', 'dateTime_end', 'ofdName', 'bootVersion', 'ffdVersion',
'fnExecution', 'INN', 'anydesk_id', 'teamviewer_id', 'lastModifiedDate')
for fr in fr_list:
# Проверка на наличие обязательных полей
if not all(fr.get(key) for key in ['serialNumber', 'modelName', 'dateTime_end']):
log.warning(f"Пропущена запись из FTP: отсутствует одно из ключевых полей "
f"(serialNumber, modelName, dateTime_end). Данные: {fr}")
continue
prepared_data.append(tuple(fr.get(col) for col in columns))
elif source == 'sd':
# Ожидаемые колонки для sd_fiscals
columns = ('UUID', 'serialNumber', 'modelName', 'RNM', 'organizationName', 'fn_serial',
'datetime_reg', 'dateTime_end', 'ofdName', 'bootVersion', 'ffdVersion',
'owner_uuid', 'lastModifiedDate')
for fr in fr_list:
if not fr.get('UUID') or not fr.get('FRSerialNumber'):
log.warning(f"Пропущена запись из SD: отсутствует UUID или FRSerialNumber. Данные: {fr}")
continue
# Извлекаем и нормализуем данные
owner = fr.get('owner')
model = fr.get('ModelKKT')
ofd = fr.get('OFDName')
ffd = fr.get('FFD')
prepared_data.append((
fr.get('UUID'),
fr.get('FRSerialNumber'),
model.get('title') if model else None,
fr.get('RNKKT'),
fr.get('LegalName'),
fr.get('FNNumber'),
fr.get('KKTRegDate'),
fr.get('FNExpireDate'),
ofd.get('title') if ofd else None,
fr.get('FRDownloader'),
ffd.get('title') if ffd else None,
owner.get('UUID') if owner else None,
fr.get('lastModifiedDate')
))
return prepared_data, columns
def _fetch_and_cache_lookups(self):
"""Запрашивает и кэширует UUID справочников из SD."""
log.info("Кэширование справочников из ServiceDesk...")
metaclasses = ['ModeliFR', 'SrokiFN', 'FFD']
for mc in metaclasses:
try:
values = self.sd.get_lookup_values(mc)
# Создаем словарь вида {'Название': 'UUID'}
self.lookup_cache[mc] = {item['title']: item['UUID'] for item in values}
except ServiceDeskAPIError as e:
log.error(f"Не удалось загрузить справочник {mc}: {e}")
# Если не удалось загрузить критически важные справочники, прерываем работу
raise
log.info("Справочники успешно закэшированы.")
def run_full_sync(self):
"""Выполняет полный цикл синхронизации."""
log.info("--- НАЧАЛО ЦИКЛА СИНХРОНИЗАЦИИ ---")
try:
# 1. Кэшируем справочники
self._fetch_and_cache_lookups()
# 2. Очищаем таблицы перед заполнением
self.db.clear_tables(['pos_fiscals', 'sd_fiscals', 'workstations'])
# 3. Сбор и загрузка данных с FTP
pos_frs_raw = ftp_parser.process_json_files(config.JSON_FILES_PATH)
pos_frs_data, pos_cols = self._prepare_data_for_db(pos_frs_raw, 'pos')
self.db.bulk_insert('pos_fiscals', pos_cols, pos_frs_data)
# 4. Сбор и загрузка данных из ServiceDesk (ФР)
sd_frs_raw = self.sd.get_all_frs()
sd_frs_data, sd_cols = self._prepare_data_for_db(sd_frs_raw, 'sd')
self.db.bulk_insert('sd_fiscals', sd_cols, sd_frs_data)
# 5. Сбор и загрузка данных из ServiceDesk (Рабочие станции)
workstations_raw = self.sd.get_all_workstations()
workstations_data = []
for ws in workstations_raw:
owner = ws.get('owner')
if ws.get('UUID') and owner and owner.get('UUID'):
workstations_data.append((
ws['UUID'],
owner['UUID'],
_clean_sd_remote_id(ws.get('AnyDesk')),
_clean_sd_remote_id(ws.get('Teamviewer'))
))
self.db.bulk_insert('workstations', ['uuid', 'owner_uuid', 'clean_anydesk_id', 'clean_teamviewer_id'], workstations_data)
# 6. Логика синхронизации
self._update_existing_frs()
self._create_new_frs()
log.info("--- ЦИКЛ СИНХРОНИЗАЦИИ УСПЕШНО ЗАВЕРШЕН ---")
except Exception as e:
log.critical(f"Критическая ошибка в цикле синхронизации: {e}", exc_info=True)
def _update_existing_frs(self):
"""Находит и обновляет ФР с отличающимися датами."""
log.info("Поиск ФР для обновления даты окончания ФН...")
query = """
SELECT
pos.serialNumber,
pos.dateTime_end AS pos_date,
pos.datetime_reg AS pos_reg_date,
pos.fn_serial AS pos_fn_serial,
pos.RNM AS pos_rnm,
pos.bootVersion AS pos_boot_version,
pos.organizationName AS pos_org_name,
pos.INN AS pos_inn,
sd.UUID AS sd_uuid,
sd.dateTime_end AS sd_date
FROM pos_fiscals pos
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')
log.info(f"Найдено {len(records_to_update)} ФР для обновления.")
for rec in records_to_update:
# Проверка, что дата действительно новее (избегаем "старых" данных с FTP)
# Это простая проверка, можно усложнить, если нужно
if parser.parse(rec['pos_date']) < parser.parse(rec['sd_date']):
log.warning(f"Пропуск обновления для S/N {rec['serialNumber']}: дата с FTP ({rec['pos_date']}) "
f"старше, чем в SD ({rec['sd_date']}).")
continue
legal_name = f"{rec['pos_org_name']} ИНН:{rec['pos_inn']}" if rec['pos_org_name'] and rec['pos_inn'] else "ЗАКОНЧИЛСЯ ФН"
params_to_update = {
'FNNumber': rec['pos_fn_serial'],
'FNExpireDate': parser.parse(rec['pos_date']).strftime('%Y.%m.%d %H:%M:%S'),
'KKTRegDate': parser.parse(rec['pos_reg_date']).strftime('%Y.%m.%d %H:%M:%S'),
'LegalName': legal_name,
'RNKKT': rec['pos_rnm'],
'FRDownloader': rec['pos_boot_version']
}
try:
self.sd.update_fr(rec['sd_uuid'], params_to_update)
except ServiceDeskAPIError as e:
log.error(f"Не удалось обновить ФР с UUID {rec['sd_uuid']}: {e}")
def _create_new_frs(self):
"""Находит, определяет владельца и создает новые ФР."""
log.info("Поиск новых ФР для добавления в ServiceDesk...")
query = """
SELECT pos.*
FROM pos_fiscals pos
LEFT JOIN sd_fiscals sd ON pos.serialNumber = sd.serialNumber
WHERE sd.serialNumber IS NULL
"""
new_frs = self.db._execute_query(query, fetch='all')
log.info(f"Найдено {len(new_frs)} новых ФР.")
for fr in new_frs:
owner_uuid = self._find_owner_uuid(fr['anydesk_id'], fr['teamviewer_id'])
# Собираем данные для создания
model_uuid = self.lookup_cache.get('ModeliFR', {}).get(fr['modelName'])
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
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
if not all([model_uuid, srok_fn_uuid, ffd_uuid]):
log.error(f"Не удалось создать ФР с S/N {fr['serialNumber']}: не найдены UUID для справочников. "
f"Model: {fr['modelName']}({model_uuid}), SrokFN: {srok_fn_str}({srok_fn_uuid}), "
f"FFD: {ffd_version_str}({ffd_uuid})")
continue
legal_name = f"{fr['organizationName']} ИНН:{fr['INN']}" if fr['organizationName'] and fr['INN'] else ""
creation_data = {
"RNKKT": fr['RNM'],
"KKTRegDate": parser.parse(fr['datetime_reg']).strftime('%Y.%m.%d %H:%M:%S'),
"FRSerialNumber": fr['serialNumber'],
"LegalName": legal_name,
"FNExpireDate": parser.parse(fr['dateTime_end']).strftime('%Y.%m.%d %H:%M:%S'),
"FNNumber": fr['fn_serial'],
"ModelKKT": model_uuid,
"SrokFN": srok_fn_uuid,
"FFD": ffd_uuid,
"owner": owner_uuid
}
try:
self.sd.create_fr(creation_data)
except ServiceDeskAPIError as e:
log.error(f"Не удалось создать ФР с S/N {fr['serialNumber']}: {e}")
def _find_owner_uuid(self, anydesk_id: Optional[str], teamviewer_id: Optional[str]) -> str:
"""Ищет владельца по ID. Возвращает UUID владельца или UUID по умолчанию."""
def query_owner(id_value, id_type_column):
if not id_value:
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')
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}.")
return owner_by_anydesk
if owner_by_anydesk:
log.info(f"Владелец {owner_by_anydesk} найден по Anydesk ID: {anydesk_id}.")
return owner_by_anydesk
if owner_by_teamviewer:
log.info(f"Владелец {owner_by_teamviewer} найден по TeamViewer ID: {teamviewer_id}.")
return owner_by_teamviewer
log.warning(f"Владелец не найден для Anydesk={anydesk_id}, TV={teamviewer_id}. "
f"Будет назначен владелец по умолчанию: {config.DEFAULT_OWNER_UUID}")
return config.DEFAULT_OWNER_UUID