Compare commits

...

5 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
29 changed files with 331 additions and 444 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}'"

3
.gitignore vendored
View File

@@ -3,4 +3,5 @@
docker-compose.yml 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 "
}

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

@@ -1,4 +1,5 @@
python-dotenv 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

@@ -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,52 +171,119 @@ 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_check = self.db._execute_query(query, fetch='all')
update_counter = 0
records_to_update = self.db._execute_query(query, fetch='all') #Порог для сравнения дат
log.info(f"Найдено {len(records_to_update)} ФР для обновления.") archive_delta = timedelta(days=365*10)
for rec in records_to_update: for rec in records_to_check:
# Проверка, что дата действительно новее (избегаем "старых" данных с 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: try:
self.sd.update_fr(rec['sd_uuid'], params_to_update) # 2. Приводим даты к одному "знаменателю" - объектам datetime
except ServiceDeskAPIError as e: # dateutil.parser отлично справляется с разными форматами
log.error(f"Не удалось обновить ФР с UUID {rec['sd_uuid']}: {e}") # Проверяем, что обе даты существуют, прежде чем их парсить
if not rec['pos_date_end'] or not rec['sd_date_end']:
log.warning(f"Пропуск сравнения для S/N {rec['serialNumber']}: одна из дат отсутствует.")
continue
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 = {
'FNNumber': rec['pos_fn_serial'],
'FNExpireDate': formatted_expire_date,
'LegalName': legal_name,
'RNKKT': rec['pos_rnm'],
'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:
self.sd.update_fr(rec['sd_uuid'], params_to_update)
update_counter += 1
except ServiceDeskAPIError as 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):
"""Находит, определяет владельца и создает новые ФР.""" """Находит, определяет владельца и создает новые ФР."""
@@ -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 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_owner(anydesk_id, 'clean_anydesk_id') owner_by_anydesk = query_best_owner(anydesk_id, 'clean_anydesk_id')
owner_by_teamviewer = query_owner(teamviewer_id, 'clean_teamviewer_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:
@@ -296,4 +391,5 @@ 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