Compare commits

..

7 Commits

Author SHA1 Message Date
0fa41e6d46 prod testing
Some checks failed
Build and Deploy / build_and_deploy (push) Failing after 17s
2025-07-24 11:40:21 +03:00
da6d345609 ready to deploy
Some checks failed
Build and Deploy / build_and_deploy (push) Failing after 10s
2025-07-24 11:28:34 +03:00
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
0413460c3a table clear fixed 2025-07-21 21:13:36 +03:00
0520a1117c dry run added 2025-07-21 20:50:35 +03:00
29 changed files with 395 additions and 446 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/

View File

@@ -0,0 +1,39 @@
name: Build and Deploy
on:
push:
branches:
- prod
jobs:
build_and_deploy:
runs-on: docker
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Define Image Name
id: image_vars
run: |
IMAGE_TAG="${{ GITEA_BRANCH }}-${{ GITEA_SHA::7 }}"
FULL_IMAGE_NAME="${{ GITEA_REPO_NAME }}:${IMAGE_TAG}"
echo "::set-output name=FULL_IMAGE_NAME::${FULL_IMAGE_NAME}"
- name: Build Docker Image
run: |
docker buildx build --load --platform linux/amd64 \
-t "${{ steps.image_vars.outputs.FULL_IMAGE_NAME }}" .
- name: Set up SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
- name: Save, Transfer and Deploy Image
run: |
IMAGE_TO_DEPLOY="${{ steps.image_vars.outputs.FULL_IMAGE_NAME }}"
echo "Transferring image ${IMAGE_TO_DEPLOY} to production..."
docker save "${IMAGE_TO_DEPLOY}" | ssh -o StrictHostKeyChecking=no deployer@YOUR_PROD_SERVER_IP "/home/deployer/deploy.sh '${IMAGE_TO_DEPLOY}'"

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@
docker-compose.yml docker-compose.yml
*.db *.db
__* __*
*.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)
@@ -32,3 +33,12 @@ FIND_WORKSTATIONS_URL = f"{SD_BASE_URL}/find/objectBase$Workstation"
CREATE_FR_URL = f"{SD_BASE_URL}/create-m2m/objectBase$FR" CREATE_FR_URL = f"{SD_BASE_URL}/create-m2m/objectBase$FR"
# URL для редактирования будет принимать UUID объекта # URL для редактирования будет принимать UUID объекта
EDIT_FR_URL_TEMPLATE = f"{SD_BASE_URL}/edit/{{uuid}}" EDIT_FR_URL_TEMPLATE = f"{SD_BASE_URL}/edit/{{uuid}}"
# --- Testing Configuration ---
# Установите в "True" для режима "сухого запуска" (dry run).
# В этом режиме запросы на изменение/создание не будут отправляться в SD,
# а будут сохраняться в локальные файлы для анализа.
DRY_RUN = os.getenv('DRY_RUN', 'True').lower() in ('true', '1', 't')
# Путь для сохранения тестовых выходных данных
TEST_OUTPUT_PATH = os.path.join(os.path.dirname(__file__), 'test_output')

View File

@@ -2,6 +2,7 @@
import sqlite3 import sqlite3
import logging import logging
import os
# Настройка логирования для модуля БД # Настройка логирования для модуля БД
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -15,6 +16,16 @@ class DatabaseManager:
self.db_path = db_path self.db_path = db_path
self.conn = None self.conn = None
# Проверяем, существует ли директория для файла БД, и создаем ее, если нет.
db_dir = os.path.dirname(self.db_path)
if db_dir and not os.path.exists(db_dir):
try:
os.makedirs(db_dir)
log.info(f"Создана директория для базы данных: {db_dir}")
except OSError as e:
log.error(f"Не удалось создать директорию для БД {db_dir}: {e}")
raise # Перевыбрасываем исключение, так как без директории работа невозможна
def __enter__(self): def __enter__(self):
"""Открывает соединение с БД при входе в контекстный менеджер.""" """Открывает соединение с БД при входе в контекстный менеджер."""
try: try:
@@ -104,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 справочников
@@ -122,7 +134,7 @@ class DatabaseManager:
log.info(f"Очистка таблиц: {', '.join(table_names)}") log.info(f"Очистка таблиц: {', '.join(table_names)}")
for table in table_names: for table in table_names:
# Проверяем, что имя таблицы "безопасное" # Проверяем, что имя таблицы "безопасное"
if table.isalnum(): if all(c.isalnum() or c == '_' for c in table):
self._execute_query(f"DELETE FROM {table}") self._execute_query(f"DELETE FROM {table}")
else: else:
log.warning(f"Попытка очистить таблицу с некорректным именем: {table}") log.warning(f"Попытка очистить таблицу с некорректным именем: {table}")

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 "
}

View File

@@ -1,9 +1,10 @@
# ftp_parser.py
import os import os
import json import json
import logging import logging
from typing import List, Dict from typing import List, Dict
from datetime import datetime, timedelta
from dateutil import parser
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -11,6 +12,7 @@ def process_json_files(directory: str) -> List[Dict]:
""" """
Сканирует директорию, читает все .json файлы и извлекает из них данные Сканирует директорию, читает все .json файлы и извлекает из них данные
о фискальных регистраторах в стандартизированном формате. о фискальных регистраторах в стандартизированном формате.
Файлы, старше 21 дня (по полю 'current_time'), игнорируются.
:param directory: Путь к директории с JSON файлами. :param directory: Путь к директории с JSON файлами.
:return: Список словарей, где каждый словарь представляет один ФР. :return: Список словарей, где каждый словарь представляет один ФР.
@@ -22,6 +24,9 @@ def process_json_files(directory: str) -> List[Dict]:
all_fr_data = [] all_fr_data = []
log.info(f"Начинаю обработку JSON файлов из директории: {directory}") log.info(f"Начинаю обработку JSON файлов из директории: {directory}")
# Пороговая дата: всё что старше, считаем неактуальным
freshness_threshold = datetime.now() - timedelta(days=21)
for filename in os.listdir(directory): for filename in os.listdir(directory):
if filename.lower().endswith('.json'): if filename.lower().endswith('.json'):
file_path = os.path.join(directory, filename) file_path = os.path.join(directory, filename)
@@ -29,6 +34,26 @@ def process_json_files(directory: str) -> List[Dict]:
with open(file_path, 'r', encoding='utf-8') as f: with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f) data = json.load(f)
current_time_str = data.get('current_time')
if not current_time_str:
log.warning(f"Пропущен файл {filename}: отсутствует поле 'current_time' для проверки актуальности.")
continue
try:
file_datetime = parser.parse(current_time_str)
# Приводим к aware-объекту с локальной таймзоной, если он naive, для корректного сравнения
if file_datetime.tzinfo is None:
file_datetime = file_datetime.astimezone()
# Сравниваем с пороговым значением (пороговое значение тоже делаем aware)
if file_datetime < freshness_threshold.astimezone(file_datetime.tzinfo):
log.warning(f"Пропущен файл {filename}: данные неактуальны (старше 21 дня). "
f"Дата файла: {file_datetime.strftime('%Y-%m-%d')}")
continue
except parser.ParserError:
log.error(f"Не удалось распознать дату в поле 'current_time' в файле {filename}: '{current_time_str}'")
continue
if 'serialNumber' not in data or not data['serialNumber']: if 'serialNumber' not in data or not data['serialNumber']:
log.warning(f"Пропущен файл {filename}: отсутствует serialNumber.") log.warning(f"Пропущен файл {filename}: отсутствует serialNumber.")
continue continue

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

@@ -3,7 +3,11 @@
import requests import requests
import logging import logging
import json import json
import os
from typing import List, Dict, Any from typing import List, Dict, Any
from datetime import datetime
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# Импортируем нашу конфигурацию # Импортируем нашу конфигурацию
import config import config
@@ -17,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:
@@ -27,36 +32,65 @@ 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
def _ensure_test_output_dir(self):
"""Проверяет и создает директорию для тестовых файлов."""
if not os.path.exists(config.TEST_OUTPUT_PATH):
os.makedirs(config.TEST_OUTPUT_PATH)
log.info(f"Создана директория для тестовых данных: {config.TEST_OUTPUT_PATH}")
def get_all_frs(self) -> List[Dict]: def get_all_frs(self) -> List[Dict]:
"""Получает список всех фискальных регистраторов.""" """Получает список всех фискальных регистраторов."""
log.info("Запрос списка всех ФР из ServiceDesk...") log.info("Запрос списка всех ФР из ServiceDesk...")
@@ -65,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]:
""" """
@@ -89,19 +124,34 @@ 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:
""" """
Обновляет существующий фискальный регистратор. Обновляет существующий фискальный регистратор.
В режиме DRY_RUN сохраняет данные в файл.
:param uuid: UUID объекта для редактирования. :param uuid: UUID объекта для редактирования.
:param data: Словарь с полями для обновления. :param data: Словарь с полями для обновления.
""" """
if config.DRY_RUN:
self._ensure_test_output_dir()
filename = f"update_{uuid}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
filepath = os.path.join(config.TEST_OUTPUT_PATH, filename)
# Сохраняем и URL, и параметры для полного понимания
output_data = {
"action": "UPDATE",
"target_uuid": uuid,
"payload": data
}
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(output_data, f, indent=4, ensure_ascii=False)
log.warning(f"[DRY RUN] Пропущено обновление {uuid}. Данные сохранены в {filepath}")
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} успешно обновлен.")
@@ -109,10 +159,25 @@ class ServiceDeskClient:
def create_fr(self, data: Dict) -> Dict: def create_fr(self, data: Dict) -> Dict:
""" """
Создает новый фискальный регистратор. Создает новый фискальный регистратор.
В режиме DRY_RUN сохраняет тело запроса в файл.
:param data: Словарь с данными для создания объекта (тело запроса). :param data: Словарь с данными для создания объекта (тело запроса).
:return: JSON-ответ от сервера, содержащий UUID нового объекта. :return: JSON-ответ от сервера, содержащий UUID нового объекта.
""" """
serial_number = data.get('FRSerialNumber', 'unknown_sn')
if config.DRY_RUN:
self._ensure_test_output_dir()
filename = f"create_{serial_number}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
filepath = os.path.join(config.TEST_OUTPUT_PATH, filename)
# Сохраняем только тело запроса
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=4, ensure_ascii=False)
log.warning(f"[DRY RUN] Пропущено создание ФР с S/N {serial_number}. Body запроса сохранено в {filepath}")
# В режиме dry run возвращаем фейковый ответ, чтобы не сломать вызывающий код
return {"action": "DRY_RUN_CREATE", "saved_to": filepath}
log.info(f"Создание нового ФР с серийным номером: {data.get('FRSerialNumber')}...") log.info(f"Создание нового ФР с серийным номером: {data.get('FRSerialNumber')}...")
# Параметр для получения UUID в ответе # Параметр для получения UUID в ответе
params = {'attrs': 'UUID'} params = {'attrs': 'UUID'}

View File

@@ -3,7 +3,7 @@
import re import re
import logging import logging
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
from datetime import datetime from datetime import datetime, timedelta
from dateutil import parser from dateutil import parser
# Импортируем наши модули # Импортируем наши модули
@@ -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,120 @@ class Synchronizer:
def _update_existing_frs(self): def _update_existing_frs(self):
"""Находит и обновляет ФР с отличающимися датами.""" """
log.info("Поиск ФР для обновления даты окончания ФН...") Находит и обновляет ФР с отличающимися датами окончания ФН.
Пропускает обновление, если разница в датах более 10 лет.
"""
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: #Порог для сравнения дат
# Проверка, что дата действительно новее (избегаем "старых" данных с FTP) archive_delta = timedelta(days=365*10)
# Это простая проверка, можно усложнить, если нужно
if parser.parse(rec['pos_date']) < parser.parse(rec['sd_date']): for rec in records_to_check:
log.warning(f"Пропуск обновления для S/N {rec['serialNumber']}: дата с FTP ({rec['pos_date']}) " try:
f"старше, чем в SD ({rec['sd_date']}).") # 2. Приводим даты к одному "знаменателю" - объектам datetime
# dateutil.parser отлично справляется с разными форматами
# Проверяем, что обе даты существуют, прежде чем их парсить
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:
# Проверяем, что разница не больше 10 лет
if abs(pos_date - sd_date) > archive_delta:
log.warning(f"Пропуск сравнения для S/N {rec['serialNumber']}: разница в датах больше 10 лет."
f"FTP: {pos_date.date()}, SD: {sd_date.date()}.")
continue
log.info(f"Найдено расхождение в дате для S/N {rec['serialNumber']} (UUID: {rec['sd_uuid']}). "
f"FTP: {pos_date}, SD: {sd_date}. Подготовка к обновлению.")
# 4. Формируем данные для обновления
# Проверяем, закончился ли ФН
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 +304,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 +340,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 +392,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