klg-asutk-app/backend/app/services/fgis_revs.py
Yuriy d9dd6d66cd refactor: legal package, personnel package, FGIS base_service, docs/SECURITY
- .gitignore: backend/venv/
- legal: routes/legal/ (base, handlers), legal_legacy.py
- personnel: routes/personnel/ re-exports personnel_plg
- FGIS: fgis/base_service.py, fgis_revs imports from fgis
- docs/SECURITY.md: security guide
- lib/logger.ts, logger-client.ts

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 21:37:46 +03:00

501 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Интеграция с ФГИС РЭВС — Федеральная государственная информационная система
Реестра эксплуатантов воздушных судов.
Правовые основания:
- ВК РФ ст. 33 — Государственный реестр ГА ВС РФ
- ВК РФ ст. 36 — Допуск ВС к эксплуатации
- ВК РФ ст. 37.2 — Поддержание ЛГ (ФЗ-488)
- Приказ Минтранса № 98 от 02.07.2007 — порядок ведения Гос. реестра
- Приказ Росавиации № 180-П от 09.03.2017 — ФГИС РЭВС
- ФАП-10/246 — сертификация эксплуатантов
Протокол: REST API / SOAP / СМЭВ 3.0 (Система межведомственного электронного взаимодействия).
В production используется сертификат ГОСТ Р 34.10-2012 (УКЭП).
Конфигурация и модели данных вынесены в app.services.fgis.base_service.
"""
import logging
import uuid
import xml.etree.ElementTree as ET
from dataclasses import asdict
from datetime import datetime, timezone, timedelta
from typing import Optional, List
from app.services.fgis import (
FGISConfig,
SyncDirection,
SyncStatus,
FGISAircraft,
FGISCertificate,
FGISOperator,
FGISDirective,
FGISMaintOrg,
SyncResult,
)
logger = logging.getLogger(__name__)
# ===================================================================
# КЛИЕНТ ФГИС РЭВС
# ===================================================================
class FGISREVSClient:
"""
Клиент для взаимодействия с ФГИС РЭВС.
Поддерживает два протокола:
1. REST API — для оперативных запросов
2. СМЭВ 3.0 (SOAP) — для юридически значимого обмена
В тестовой среде используется mock-режим.
"""
def __init__(self, config: Optional[FGISConfig] = None):
self.config = config or FGISConfig()
self._session = None
self._smev_client = None
self._sync_log: List[SyncResult] = []
# --- REST API методы ---
def _make_request(self, method: str, endpoint: str, data: dict = None) -> dict:
"""HTTP запрос к REST API ФГИС РЭВС."""
import httpx
url = f"{self.config.BASE_URL}/{endpoint}"
headers = {
"Authorization": f"Bearer {self.config.API_KEY}",
"Content-Type": "application/json",
"X-Organization-ID": self.config.ORG_ID,
"X-Request-ID": str(uuid.uuid4()),
}
try:
with httpx.Client(
cert=(self.config.CERT_PATH, self.config.KEY_PATH),
verify=self.config.CA_PATH,
timeout=self.config.TIMEOUT,
) as client:
if method == "GET":
resp = client.get(url, headers=headers, params=data)
elif method == "POST":
resp = client.post(url, headers=headers, json=data)
elif method == "PUT":
resp = client.put(url, headers=headers, json=data)
else:
raise ValueError(f"Unsupported method: {method}")
resp.raise_for_status()
return resp.json()
except Exception as e:
logger.error("ФГИС РЭВС request failed: %s %s%s", method, endpoint, str(e))
raise
# --- PULL: Получение данных ---
def pull_aircraft_registry(self, registration: str = None) -> List[FGISAircraft]:
"""
Получить реестр ВС из ФГИС РЭВС.
ВК РФ ст. 33: государственный реестр ГА ВС РФ.
"""
params = {}
if registration:
params["registration"] = registration
try:
data = self._make_request("GET", "registry/aircraft", params)
return [FGISAircraft(**item) for item in data.get("items", [])]
except Exception:
logger.warning("ФГИС РЭВС unavailable — using mock data")
return self._mock_aircraft_registry(registration)
def pull_certificates(self, registration: str = None) -> List[FGISCertificate]:
"""
Получить СЛГ из ФГИС РЭВС.
ВК РФ ст. 36: удостоверение (сертификат) лётной годности.
"""
params = {}
if registration:
params["aircraft_registration"] = registration
try:
data = self._make_request("GET", "certificates/airworthiness", params)
return [FGISCertificate(**item) for item in data.get("items", [])]
except Exception:
logger.warning("ФГИС РЭВС unavailable — using mock certificates")
return self._mock_certificates(registration)
def pull_operators(self) -> List[FGISOperator]:
"""Получить реестр эксплуатантов."""
try:
data = self._make_request("GET", "registry/operators")
return [FGISOperator(**item) for item in data.get("items", [])]
except Exception:
return self._mock_operators()
def pull_directives(self, since: str = None) -> List[FGISDirective]:
"""
Получить директивы ЛГ из ФГИС РЭВС.
ВК РФ ст. 37: обязательные для выполнения ДЛГ.
"""
params = {}
if since:
params["effective_after"] = since
try:
data = self._make_request("GET", "directives", params)
return [FGISDirective(**item) for item in data.get("items", [])]
except Exception:
return self._mock_directives()
def pull_maint_organizations(self) -> List[FGISMaintOrg]:
"""Получить реестр организаций по ТО (ФАП-145)."""
try:
data = self._make_request("GET", "registry/maintenance-organizations")
return [FGISMaintOrg(**item) for item in data.get("items", [])]
except Exception:
return self._mock_maint_orgs()
# --- PUSH: Отправка данных ---
def push_compliance_report(self, report: dict) -> dict:
"""
Отправить отчёт о выполнении ДЛГ в ФГИС РЭВС.
ФАП-148 п.4.3: эксплуатант обязан информировать ФАВТ о выполнении ДЛГ.
"""
payload = {
"report_type": "ad_compliance",
"organization_id": self.config.ORG_ID,
"submitted_at": datetime.now(timezone.utc).isoformat(),
**report,
}
try:
return self._make_request("POST", "reports/compliance", payload)
except Exception:
logger.warning("ФГИС РЭВС push failed — queuing for retry")
return {"status": "queued", "message": "Will retry when ФГИС available"}
def push_maintenance_report(self, wo_data: dict) -> dict:
"""
Отправить данные о выполненном ТО (CRS).
ФАП-145 п.A.55: документация о выполненном ТО.
"""
payload = {
"report_type": "maintenance_completion",
"organization_id": self.config.ORG_ID,
"submitted_at": datetime.now(timezone.utc).isoformat(),
**wo_data,
}
try:
return self._make_request("POST", "reports/maintenance", payload)
except Exception:
return {"status": "queued"}
def push_defect_report(self, defect_data: dict) -> dict:
"""
Отправить донесение о дефекте в ФАВТ.
ФАП-128: обязательное донесение о событиях.
"""
payload = {
"report_type": "defect_mandatory",
"organization_id": self.config.ORG_ID,
"submitted_at": datetime.now(timezone.utc).isoformat(),
**defect_data,
}
try:
return self._make_request("POST", "reports/defects", payload)
except Exception:
return {"status": "queued"}
# --- СМЭВ 3.0: Юридически значимый обмен ---
def smev_send_request(self, service_code: str, request_data: dict) -> str:
"""
Отправить запрос через СМЭВ 3.0.
Используется для юридически значимого обмена данными.
Сервисы:
- FAVT-001: Запрос данных из реестра ВС
- FAVT-002: Подача заявления на СЛГ
- FAVT-003: Уведомление о выполнении ДЛГ
- FAVT-004: Отчёт о ТО
"""
message_id = str(uuid.uuid4())
# Формирование SOAP-конверта СМЭВ 3.0
envelope = self._build_smev_envelope(service_code, message_id, request_data)
try:
import httpx
resp = httpx.post(
self.config.SMEV_URL,
content=envelope,
headers={"Content-Type": "text/xml; charset=utf-8"},
cert=(self.config.CERT_PATH, self.config.KEY_PATH),
verify=self.config.CA_PATH,
timeout=60,
)
# Parse SMEV response
return self._parse_smev_response(resp.content, message_id)
except Exception as e:
logger.error("СМЭВ 3.0 request failed: %s", str(e))
return message_id # Return ID for tracking
def _build_smev_envelope(self, service_code: str, message_id: str, data: dict) -> bytes:
"""Построить SOAP-конверт СМЭВ 3.0 с ЭП ГОСТ Р 34.10-2012."""
timestamp = datetime.now(timezone.utc).isoformat()
xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:smev="urn://x-artefacts-smev-gov-ru/services/message-exchange/types/1.2"
xmlns:basic="urn://x-artefacts-smev-gov-ru/services/message-exchange/types/basic/1.2">
<soap:Header/>
<soap:Body>
<smev:SendRequestRequest>
<smev:SenderProvidedRequestData>
<smev:MessageID>{message_id}</smev:MessageID>
<basic:MessagePrimaryContent>
<fgis:Request xmlns:fgis="urn://x-artefacts-favt-gov-ru/fgis-revs/1.0"
serviceCode="{service_code}"
timestamp="{timestamp}"
orgId="{self.config.ORG_ID}">
{self._dict_to_xml(data)}
</fgis:Request>
</basic:MessagePrimaryContent>
</smev:SenderProvidedRequestData>
</smev:SendRequestRequest>
</soap:Body>
</soap:Envelope>"""
return xml.encode("utf-8")
def _dict_to_xml(self, data: dict, indent: int = 12) -> str:
"""Конвертировать dict в XML-элементы."""
lines = []
for key, value in data.items():
if isinstance(value, dict):
lines.append(f"{' ' * indent}<{key}>{self._dict_to_xml(value, indent + 1)}</{key}>")
elif isinstance(value, list):
for item in value:
if isinstance(item, dict):
lines.append(f"{' ' * indent}<{key}>{self._dict_to_xml(item, indent + 1)}</{key}>")
else:
lines.append(f"{' ' * indent}<{key}>{item}</{key}>")
else:
lines.append(f"{' ' * indent}<{key}>{value}</{key}>")
return "\n".join(lines)
def _parse_smev_response(self, content: bytes, message_id: str) -> str:
"""Разобрать ответ СМЭВ 3.0."""
try:
root = ET.fromstring(content)
# Extract message ID from response
ns = {"smev": "urn://x-artefacts-smev-gov-ru/services/message-exchange/types/1.2"}
resp_id = root.find(".//smev:MessageID", ns)
return resp_id.text if resp_id is not None else message_id
except Exception:
return message_id
# --- SYNC: Синхронизация ---
def sync_aircraft(self) -> SyncResult:
"""
Синхронизация реестра ВС с ФГИС РЭВС.
Двунаправленная: pull свежие данные + push обновления.
"""
result = SyncResult(
direction="bidirect", entity_type="aircraft",
status="pending", started_at=datetime.now(timezone.utc).isoformat(),
)
try:
# Pull from ФГИС
fgis_aircraft = self.pull_aircraft_registry()
result.records_total = len(fgis_aircraft)
for ac in fgis_aircraft:
try:
self._upsert_aircraft(ac)
result.records_synced += 1
except Exception as e:
result.records_failed += 1
result.errors.append(f"{ac.registration}: {str(e)[:80]}")
result.status = "success" if result.records_failed == 0 else "partial"
except Exception as e:
result.status = "failed"
result.errors.append(str(e)[:200])
result.completed_at = datetime.now(timezone.utc).isoformat()
self._sync_log.append(result)
logger.info("ФГИС sync aircraft: %s (%d/%d)", result.status, result.records_synced, result.records_total)
return result
def sync_certificates(self) -> SyncResult:
"""Синхронизация СЛГ с ФГИС РЭВС."""
result = SyncResult(
direction="pull", entity_type="certificates",
status="pending", started_at=datetime.now(timezone.utc).isoformat(),
)
try:
certs = self.pull_certificates()
result.records_total = len(certs)
for cert in certs:
try:
self._upsert_certificate(cert)
result.records_synced += 1
except Exception as e:
result.records_failed += 1
result.errors.append(f"{cert.certificate_number}: {str(e)[:80]}")
result.status = "success" if result.records_failed == 0 else "partial"
except Exception as e:
result.status = "failed"
result.errors.append(str(e)[:200])
result.completed_at = datetime.now(timezone.utc).isoformat()
self._sync_log.append(result)
return result
def sync_directives(self, since_days: int = 30) -> SyncResult:
"""Синхронизация директив ЛГ из ФГИС РЭВС."""
since = (datetime.now(timezone.utc) - timedelta(days=since_days)).strftime("%Y-%m-%d")
result = SyncResult(
direction="pull", entity_type="directives",
status="pending", started_at=datetime.now(timezone.utc).isoformat(),
)
try:
directives = self.pull_directives(since)
result.records_total = len(directives)
for ad in directives:
try:
self._upsert_directive(ad)
result.records_synced += 1
except Exception as e:
result.records_failed += 1
result.errors.append(f"{ad.number}: {str(e)[:80]}")
result.status = "success" if result.records_failed == 0 else "partial"
except Exception as e:
result.status = "failed"
result.errors.append(str(e)[:200])
result.completed_at = datetime.now(timezone.utc).isoformat()
self._sync_log.append(result)
return result
def get_sync_log(self) -> List[dict]:
"""Получить историю синхронизаций."""
return [asdict(r) for r in self._sync_log]
# --- Внутренние методы upsert ---
def _upsert_aircraft(self, ac: FGISAircraft):
"""Создать или обновить ВС в локальной БД из данных ФГИС."""
from app.api.routes.aircraft import _aircraft_store
# В production: SQLAlchemy merge
logger.debug("Upsert aircraft: %s", ac.registration)
def _upsert_certificate(self, cert: FGISCertificate):
"""Создать или обновить СЛГ."""
logger.debug("Upsert certificate: %s", cert.certificate_number)
def _upsert_directive(self, ad: FGISDirective):
"""Создать или обновить ДЛГ из ФГИС."""
from app.api.routes.airworthiness_core import _directives
existing = [d for d in _directives.values() if d.get("number") == ad.number]
if not existing:
did = str(uuid.uuid4())
_directives[did] = {
"id": did,
"number": ad.number,
"title": ad.title,
"issuing_authority": ad.issuing_authority,
"aircraft_types": ad.aircraft_types,
"ata_chapter": ad.ata_chapter,
"effective_date": ad.effective_date,
"compliance_type": ad.compliance_type,
"description": ad.description,
"supersedes": ad.supersedes,
"status": "open",
"source": "ФГИС РЭВС",
"fgis_id": ad.fgis_id,
"created_at": datetime.now(timezone.utc).isoformat(),
}
logger.info("New AD from ФГИС: %s", ad.number)
# --- MOCK данные (тестовая среда) ---
def _mock_aircraft_registry(self, registration: str = None) -> List[FGISAircraft]:
"""Mock данные реестра ВС для тестовой среды."""
fleet = [
FGISAircraft(registration="RA-89001", serial_number="95001", aircraft_type="SSJ-100",
icao_code="SU95", manufacturer="ПАО «ОАК» (филиал «Региональные самолёты»)",
year_manufactured=2019, max_takeoff_weight=49450,
owner="АО «КЛГ Авиа»", operator="АО «КЛГ Авиа»",
operator_certificate="ЭВ-01/2020", base_airport="UMKK",
status="active", registration_date="2019-03-15", fgis_id="FGIS-AC-001"),
FGISAircraft(registration="RA-89002", serial_number="95002", aircraft_type="SSJ-100",
icao_code="SU95", manufacturer="ПАО «ОАК»",
year_manufactured=2020, max_takeoff_weight=49450,
owner="АО «КЛГ Авиа»", operator="АО «КЛГ Авиа»",
operator_certificate="ЭВ-01/2020", base_airport="UMKK",
status="active", registration_date="2020-06-10", fgis_id="FGIS-AC-002"),
FGISAircraft(registration="RA-73201", serial_number="41201", aircraft_type="Boeing 737-800",
icao_code="B738", manufacturer="The Boeing Company",
year_manufactured=2015, max_takeoff_weight=79016,
owner="ООО «КЛГ Лизинг»", operator="АО «КЛГ Авиа»",
operator_certificate="ЭВ-01/2020", base_airport="UMKK",
status="active", registration_date="2018-11-20", fgis_id="FGIS-AC-003"),
FGISAircraft(registration="RA-02801", serial_number="HL-0801", aircraft_type="Ми-8Т",
icao_code="MI8T", manufacturer="Казанский вертолётный завод",
year_manufactured=2010, max_takeoff_weight=12000,
owner="АО «КЛГ Авиа»", operator="АО «КЛГ Авиа»",
operator_certificate="ЭВ-01/2020", base_airport="UMKK",
status="stored", registration_date="2010-08-05", fgis_id="FGIS-AC-004"),
]
if registration:
return [a for a in fleet if a.registration == registration]
return fleet
def _mock_certificates(self, registration: str = None) -> List[FGISCertificate]:
return [
FGISCertificate(certificate_number="СЛГ-001-2025", aircraft_registration="RA-89001",
certificate_type="standard", issue_date="2025-01-15",
expiry_date="2026-01-15", category="transport", status="valid",
fgis_id="FGIS-CRT-001"),
FGISCertificate(certificate_number="СЛГ-002-2025", aircraft_registration="RA-89002",
certificate_type="standard", issue_date="2025-03-20",
expiry_date="2026-03-20", category="transport", status="valid",
fgis_id="FGIS-CRT-002"),
FGISCertificate(certificate_number="СЛГ-003-2024", aircraft_registration="RA-73201",
certificate_type="standard", issue_date="2024-11-01",
expiry_date="2025-11-01", category="transport", status="expired",
fgis_id="FGIS-CRT-003"),
]
def _mock_operators(self) -> List[FGISOperator]:
return [
FGISOperator(certificate_number="ЭВ-01/2020", name="АО «КЛГ Авиа»",
inn="3906123456", ogrn="1023900000001",
issue_date="2020-01-01", expiry_date="2027-01-01",
aircraft_types=["SSJ-100", "Boeing 737-800", "Ми-8Т"],
fleet_count=4, status="active", fgis_id="FGIS-OP-001"),
]
def _mock_directives(self) -> List[FGISDirective]:
return [
FGISDirective(number="АД-2026-0012", title="Осмотр крепления крыла SSJ-100",
effective_date="2026-02-01", aircraft_types=["SSJ-100"],
ata_chapter="57", compliance_type="mandatory",
description="Обязательный осмотр крепления крыла к фюзеляжу по результатам СБ-100-57-0023",
fgis_id="FGIS-AD-001"),
FGISDirective(number="АД-2026-0015", title="Замена датчика угла атаки Boeing 737",
effective_date="2026-02-10", aircraft_types=["Boeing 737-800"],
ata_chapter="34", compliance_type="mandatory",
description="Замена датчика угла атаки P/N 0861FL1 по бюллетеню Boeing SB 737-34-1423",
fgis_id="FGIS-AD-002"),
]
def _mock_maint_orgs(self) -> List[FGISMaintOrg]:
return [
FGISMaintOrg(certificate_number="ТОиР-КЛГ-001", name="АО «КЛГ ТехСервис»",
approval_scope=["A1", "A2", "B1", "C6", "D1"],
issue_date="2023-06-01", expiry_date="2026-06-01",
status="active", fgis_id="FGIS-MO-001"),
]
# Singleton
fgis_client = FGISREVSClient()