Compare commits
20 Commits
3d451ded5b
...
prod
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fa41e6d46 | |||
| da6d345609 | |||
| 1c14f9bcc1 | |||
| c2ff6d8aad | |||
| f9e5d73868 | |||
| 0413460c3a | |||
| 0520a1117c | |||
| c677e4f8af | |||
| a6e21a7e59 | |||
| 1e950443ad | |||
|
|
b708d50f53 | ||
| 0552be01bd | |||
| 538db4d006 | |||
| add2b70b5a | |||
| 656b6f61bb | |||
|
|
8d7eaac569 | ||
| 2eb133f071 | |||
|
|
7249468bf6 | ||
|
|
c29a907de9 | ||
|
|
9cf312c975 |
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
||||
# .dockerignore
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
.env
|
||||
*.db
|
||||
*.db-journal
|
||||
test_output/
|
||||
logs/
|
||||
.git
|
||||
.gitignore
|
||||
.idea/
|
||||
39
.gitea/workflows/deploy.yml
Normal file
39
.gitea/workflows/deploy.yml
Normal 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}'"
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,2 +1,7 @@
|
||||
/.venv
|
||||
.venv
|
||||
.env
|
||||
docker-compose.yml
|
||||
*.db
|
||||
__*
|
||||
*.json
|
||||
files/*
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal 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"]
|
||||
149
app.py
149
app.py
@@ -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()
|
||||
44
config.py
Normal file
44
config.py
Normal file
@@ -0,0 +1,44 @@
|
||||
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 ---
|
||||
# Директория для хранения лог-файлов
|
||||
LOG_PATH = os.getenv("LOGPATH", ".")
|
||||
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}}"
|
||||
|
||||
# --- 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')
|
||||
165
database.py
Normal file
165
database.py
Normal file
@@ -0,0 +1,165 @@
|
||||
# database.py
|
||||
|
||||
import sqlite3
|
||||
import logging
|
||||
import os
|
||||
|
||||
# Настройка логирования для модуля БД
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
class DatabaseManager:
|
||||
"""
|
||||
Класс для управления всеми операциями с базой данных SQLite.
|
||||
Обеспечивает централизованное подключение и выполнение запросов.
|
||||
"""
|
||||
def __init__(self, db_path):
|
||||
self.db_path = db_path
|
||||
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):
|
||||
"""Открывает соединение с БД при входе в контекстный менеджер."""
|
||||
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,
|
||||
lastModifiedDate 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 all(c.isalnum() or c == '_' for c in table):
|
||||
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
|
||||
15
dockerfile
15
dockerfile
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 "
|
||||
}
|
||||
@@ -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 "
|
||||
}
|
||||
@@ -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 "
|
||||
}
|
||||
91
ftp_parser.py
Normal file
91
ftp_parser.py
Normal file
@@ -0,0 +1,91 @@
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Dict
|
||||
from datetime import datetime, timedelta
|
||||
from dateutil import parser
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
def process_json_files(directory: str) -> List[Dict]:
|
||||
"""
|
||||
Сканирует директорию, читает все .json файлы и извлекает из них данные
|
||||
о фискальных регистраторах в стандартизированном формате.
|
||||
Файлы, старше 21 дня (по полю 'current_time'), игнорируются.
|
||||
|
||||
:param directory: Путь к директории с JSON файлами.
|
||||
:return: Список словарей, где каждый словарь представляет один ФР.
|
||||
"""
|
||||
if not os.path.isdir(directory):
|
||||
log.error(f"Указанный путь не является директорией: {directory}")
|
||||
return []
|
||||
|
||||
all_fr_data = []
|
||||
log.info(f"Начинаю обработку JSON файлов из директории: {directory}")
|
||||
|
||||
# Пороговая дата: всё что старше, считаем неактуальным
|
||||
freshness_threshold = datetime.now() - timedelta(days=21)
|
||||
|
||||
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)
|
||||
|
||||
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']:
|
||||
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
|
||||
103
main.py
Normal file
103
main.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import schedule
|
||||
|
||||
import config
|
||||
from database import DatabaseManager
|
||||
from sd_api import ServiceDeskClient
|
||||
from sync_logic import Synchronizer
|
||||
|
||||
def setup_logging():
|
||||
"""
|
||||
Настраивает продвинутую конфигурацию логирования с ротацией файлов
|
||||
и разными уровнями для консоли и файла.
|
||||
"""
|
||||
# 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("urllib3").setLevel(logging.WARNING)
|
||||
logging.getLogger("schedule").setLevel(logging.INFO)
|
||||
|
||||
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)
|
||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
186
sd_api.py
Normal file
186
sd_api.py
Normal file
@@ -0,0 +1,186 @@
|
||||
# sd_api.py
|
||||
|
||||
import requests
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
from typing import List, Dict, Any
|
||||
from datetime import datetime
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
# Импортируем нашу конфигурацию
|
||||
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}
|
||||
|
||||
# --- НАСТРОЙКА ЛОГИКИ ПОВТОРНЫХ ЗАПРОСОВ ---
|
||||
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:
|
||||
"""
|
||||
Внутренний метод для выполнения HTTP-запросов.
|
||||
Теперь он автоматически использует логику retry, настроенную в __init__.
|
||||
|
||||
: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
|
||||
|
||||
# Некоторые ответы могут быть пустыми (например, при успешном редактировании - 204)
|
||||
# или успешном создании (201)
|
||||
if response.status_code in [204, 201] or not response.text:
|
||||
return None
|
||||
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
# Эта ошибка возникнет, если после всех попыток сервер все равно вернул 4xx или 5xx
|
||||
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:
|
||||
# Эта ошибка возникнет, если после всех попыток не удалось подключиться к серверу (например, DNS или таймаут)
|
||||
error_message = f"Request failed for URL {url}: {e}"
|
||||
log.error(error_message)
|
||||
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]:
|
||||
"""Получает список всех фискальных регистраторов."""
|
||||
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)} записей о ФР.")
|
||||
# frs может быть None если ответ пустой, вернем пустой список для консистентности
|
||||
return frs or []
|
||||
|
||||
def get_all_workstations(self) -> List[Dict]:
|
||||
"""Получает список всех рабочих станций."""
|
||||
log.info("Запрос списка всех рабочих станций из ServiceDesk...")
|
||||
params = {
|
||||
'attrs': 'UUID,owner,AnyDesk,Teamviewer,lastModifiedDate'
|
||||
}
|
||||
workstations = self._make_request('POST', config.FIND_WORKSTATIONS_URL, params=params)
|
||||
log.info(f"Получено {len(workstations)} записей о рабочих станциях.")
|
||||
return workstations or []
|
||||
|
||||
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 or []
|
||||
|
||||
def update_fr(self, uuid: str, data: Dict) -> None:
|
||||
"""
|
||||
Обновляет существующий фискальный регистратор.
|
||||
В режиме DRY_RUN сохраняет данные в файл.
|
||||
|
||||
:param uuid: UUID объекта для редактирования.
|
||||
: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}...")
|
||||
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:
|
||||
"""
|
||||
Создает новый фискальный регистратор.
|
||||
В режиме DRY_RUN сохраняет тело запроса в файл.
|
||||
|
||||
:param data: Словарь с данными для создания объекта (тело запроса).
|
||||
: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')}...")
|
||||
# Параметр для получения UUID в ответе
|
||||
params = {'attrs': 'UUID'}
|
||||
response = self._make_request('POST', config.CREATE_FR_URL, params=params, json_data=data)
|
||||
log.info(f"ФР успешно создан. Ответ сервера: {response}")
|
||||
return response
|
||||
395
sync_logic.py
Normal file
395
sync_logic.py
Normal file
@@ -0,0 +1,395 @@
|
||||
# sync_logic.py
|
||||
|
||||
import re
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from datetime import datetime, timedelta
|
||||
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..
|
||||
"""
|
||||
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')),
|
||||
ws.get('lastModifiedDate')
|
||||
))
|
||||
self.db.bulk_insert('workstations', ['uuid', 'owner_uuid', 'clean_anydesk_id', 'clean_teamviewer_id', 'lastModifiedDate'], 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):
|
||||
"""
|
||||
Находит и обновляет ФР с отличающимися датами окончания ФН.
|
||||
Пропускает обновление, если разница в датах более 10 лет.
|
||||
"""
|
||||
log.info("Поиск ФР для обновления...")
|
||||
|
||||
# 1. SQL-запрос для поиска расхождений.
|
||||
# Он объединяет таблицы по serialNumber и выбирает все необходимые поля
|
||||
# сразу, чтобы избежать вложенных циклов.
|
||||
query = """
|
||||
SELECT
|
||||
pos.serialNumber,
|
||||
pos.dateTime_end AS pos_date_end,
|
||||
pos.datetime_reg AS pos_date_reg,
|
||||
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_end
|
||||
FROM pos_fiscals pos
|
||||
JOIN sd_fiscals sd ON pos.serialNumber = sd.serialNumber
|
||||
"""
|
||||
|
||||
records_to_check = self.db._execute_query(query, fetch='all')
|
||||
update_counter = 0
|
||||
|
||||
#Порог для сравнения дат
|
||||
archive_delta = timedelta(days=365*10)
|
||||
|
||||
for rec in records_to_check:
|
||||
try:
|
||||
# 2. Приводим даты к одному "знаменателю" - объектам datetime
|
||||
# dateutil.parser отлично справляется с разными форматами
|
||||
# Проверяем, что обе даты существуют, прежде чем их парсить
|
||||
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):
|
||||
"""Находит, определяет владельца и создает новые ФР."""
|
||||
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'])
|
||||
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_uuid = self._find_lookup_uuid_by_substring('FFD', ffd_version_str)
|
||||
|
||||
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. При наличии нескольких совпадений для одного ID,
|
||||
выбирает запись с самой свежей датой lastModifiedDate.
|
||||
Возвращает UUID владельца или UUID по умолчанию.
|
||||
"""
|
||||
|
||||
def query_best_owner(id_value: Optional[str], id_type_column: str) -> Optional[str]:
|
||||
"""
|
||||
Запрашивает всех владельцев по ID и возвращает UUID самого "свежего".
|
||||
"""
|
||||
if not id_value:
|
||||
return 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_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:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user