""" Панель регулятора ФАВТ — Read-only endpoints. Данные предоставляются согласно: - ВК РФ (ст. 8, 36, 37, 67, 68) — сертификация, поддержание лётной годности - ФАП-246 (приказ Минтранса № 246 от 13.08.2015) — сертификация эксплуатантов - ФАП-285 (приказ Минтранса № 285 от 25.09.2015) — поддержание лётной годности - ФГИС РЭВС (приказ Росавиации № 180-П от 09.03.2017) — реестр эксплуатантов и ВС - ICAO Annex 8 (Airworthiness) — continuing airworthiness, state oversight - ICAO Annex 6 (Operation of Aircraft) — operator certification - ICAO Doc 9760 (Airworthiness Manual) — state safety oversight - EASA Part-M / Part-CAMO — continuing airworthiness management (аналог) - EASA Part-145 — maintenance organization approvals (аналог) ПРИНЦИП: Регулятор видит ТОЛЬКО агрегированные / обезличенные данные, необходимые для функции государственного надзора (oversight). Коммерческая тайна и персональные данные НЕ раскрываются. """ import logging from datetime import datetime, timezone, timedelta from typing import Optional from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from sqlalchemy import func, case from app.api.deps import get_db, get_current_user, require_roles from app.models import Aircraft, Organization, CertApplication, RiskAlert, Audit logger = logging.getLogger(__name__) router = APIRouter( prefix="/regulator", tags=["regulator-favt"], ) # === Access: only favt_inspector and admin === FAVT_ROLES = Depends(require_roles("favt_inspector", "admin")) # ----------------------------------------------------------------------- # 1. СВОДНАЯ СТАТИСТИКА (Overview) # ВК РФ ст. 8: функция надзора за соблюдением ФАП # ICAO Doc 9734 (Safety Oversight Manual): CE-7 surveillance obligations # ----------------------------------------------------------------------- @router.get("/overview", dependencies=[FAVT_ROLES]) def regulator_overview(db: Session = Depends(get_db)): """ Сводные показатели подконтрольных организаций. Не содержит персональных данных — только агрегированные метрики. """ now = datetime.now(timezone.utc) month_ago = now - timedelta(days=30) # Воздушные суда по статусу лётной годности aircraft_stats = db.query( func.count(Aircraft.id).label("total"), func.count(case((Aircraft.status == "active", 1))).label("airworthy"), func.count(case((Aircraft.status == "maintenance", 1))).label("in_maintenance"), func.count(case((Aircraft.status == "grounded", 1))).label("grounded"), func.count(case((Aircraft.status == "decommissioned", 1))).label("decommissioned"), ).first() # Организации по типу org_total = db.query(func.count(Organization.id)).scalar() or 0 # Заявки на сертификацию cert_stats = db.query( func.count(CertApplication.id).label("total"), func.count(case((CertApplication.status == "pending", 1))).label("pending"), func.count(case((CertApplication.status == "approved", 1))).label("approved"), func.count(case((CertApplication.status == "rejected", 1))).label("rejected"), ).first() # Риски risk_stats = db.query( func.count(RiskAlert.id).label("total"), func.count(case((RiskAlert.severity == "critical", 1))).label("critical"), func.count(case((RiskAlert.severity == "high", 1))).label("high"), func.count(case((RiskAlert.resolved == False, 1))).label("unresolved"), ).first() # Аудиты за 30 дней audit_count = db.query(func.count(Audit.id)).filter( Audit.created_at >= month_ago ).scalar() or 0 return { "generated_at": now.isoformat(), "report_period": "current", "legal_basis": [ "ВК РФ ст. 8, 35, 36, 37, 37.2 (60-ФЗ)", "ФЗ-488 от 30.12.2021 — ст. 37.2 ВК РФ «Поддержание ЛГ»", "ФАП-21 (приказ Минтранса № 184 от 17.06.2019)", "ФАП-10 / ФАП-246 (серт. требования к эксплуатантам)", "ФАП-128 (подготовка и выполнение полётов)", "ФАП-145 (приказ Минтранса № 367 от 18.10.2024) — ТО ГВС", "ФАП-147 (требования к членам экипажей, спец. по ТО)", "ФАП-148 (требования к эксплуатантам по ПЛГ)", "ФАП-149 (электросветотехническое обеспечение)", "ICAO Annex 6 — Operation of Aircraft", "ICAO Annex 8 — Airworthiness of Aircraft", "ICAO Annex 19 — Safety Management", "ICAO Doc 9734 — Safety Oversight Manual", "ICAO Doc 9760 — Airworthiness Manual", "EASA Part-M / Part-CAMO — Continuing Airworthiness", "EASA Part-145 — Maintenance Organisation", "Поручение Президента РФ Пр-1379 от 17.07.2019", "ТЗ АСУ ТК (утв. зам. министра транспорта 24.07.2022)", ], "aircraft": { "total": aircraft_stats.total if aircraft_stats else 0, "airworthy": aircraft_stats.airworthy if aircraft_stats else 0, "in_maintenance": aircraft_stats.in_maintenance if aircraft_stats else 0, "grounded": aircraft_stats.grounded if aircraft_stats else 0, "decommissioned": aircraft_stats.decommissioned if aircraft_stats else 0, }, "organizations": { "total": org_total, }, "certification": { "total_applications": cert_stats.total if cert_stats else 0, "pending": cert_stats.pending if cert_stats else 0, "approved": cert_stats.approved if cert_stats else 0, "rejected": cert_stats.rejected if cert_stats else 0, }, "safety": { "total_risks": risk_stats.total if risk_stats else 0, "critical": risk_stats.critical if risk_stats else 0, "high": risk_stats.high if risk_stats else 0, "unresolved": risk_stats.unresolved if risk_stats else 0, }, "audits_last_30d": audit_count, } # ----------------------------------------------------------------------- # 2. РЕЕСТР ВС (Aircraft Register) # ВК РФ ст. 33: Государственный реестр гражданских ВС # ФГИС РЭВС (приказ Росавиации № 180-П) # ICAO Annex 7 — Aircraft Nationality and Registration Marks # ----------------------------------------------------------------------- @router.get("/aircraft-register", dependencies=[FAVT_ROLES]) def aircraft_register( db: Session = Depends(get_db), status: Optional[str] = Query(None, description="Фильтр: active, grounded, maintenance"), aircraft_type: Optional[str] = Query(None, description="Тип ВС"), page: int = Query(1, ge=1), per_page: int = Query(50, le=200), ): """ Реестр ВС — данные, аналогичные ФГИС РЭВС. Раскрываются: рег. знак, тип, статус годности, эксплуатант (название). НЕ раскрываются: серийные номера двигателей, стоимость, детали ТО. """ q = db.query(Aircraft) if status: q = q.filter(Aircraft.status == status) if aircraft_type: q = q.filter(Aircraft.aircraft_type.ilike(f"%{aircraft_type}%")) total = q.count() items = q.order_by(Aircraft.registration_number).offset( (page - 1) * per_page ).limit(per_page).all() return { "total": total, "page": page, "per_page": per_page, "legal_basis": "ВК РФ ст. 33; ФГИС РЭВС; ICAO Annex 7", "items": [ { "registration_number": a.registration_number, "aircraft_type": a.aircraft_type, "status": a.status, "organization": a.organization.name if hasattr(a, 'organization') and a.organization else None, "cert_expiry": a.cert_expiry.isoformat() if hasattr(a, 'cert_expiry') and a.cert_expiry else None, } for a in items ], } # ----------------------------------------------------------------------- # 3. СЕРТИФИКАЦИЯ ЭКСПЛУАТАНТОВ (Operator Certification) # ФАП-246: сертификация эксплуатантов КВП # ICAO Annex 6 Part I: AOC requirements # EASA Part-ORO (аналог): organization requirements for air operations # ----------------------------------------------------------------------- @router.get("/certifications", dependencies=[FAVT_ROLES]) def certification_applications( db: Session = Depends(get_db), status: Optional[str] = Query(None), page: int = Query(1, ge=1), per_page: int = Query(50, le=200), ): """ Заявки на сертификацию / продление сертификата эксплуатанта. Раскрываются: тип заявки, статус, дата, организация (название). НЕ раскрываются: внутренние комментарии, персональные данные заявителя. """ q = db.query(CertApplication) if status: q = q.filter(CertApplication.status == status) total = q.count() items = q.order_by(CertApplication.created_at.desc()).offset( (page - 1) * per_page ).limit(per_page).all() return { "total": total, "page": page, "per_page": per_page, "legal_basis": "ФАП-246; ICAO Annex 6; EASA Part-ORO", "items": [ { "id": str(c.id), "type": c.type if hasattr(c, 'type') else "certification", "status": c.status, "organization": c.organization.name if hasattr(c, 'organization') and c.organization else None, "submitted_at": c.created_at.isoformat() if c.created_at else None, } for c in items ], } # ----------------------------------------------------------------------- # 4. ПОКАЗАТЕЛИ БЕЗОПАСНОСТИ ПОЛЁТОВ (Safety Indicators) # ВК РФ ст. 24.1: ГПБП — Государственная программа обеспечения БП # ICAO Annex 19 — Safety Management # ICAO Doc 9859 (SMM) — Safety Management Manual # EASA Part-ORO.GEN.200(a)(6): management system / safety reporting # ----------------------------------------------------------------------- @router.get("/safety-indicators", dependencies=[FAVT_ROLES]) def safety_indicators( db: Session = Depends(get_db), days: int = Query(90, ge=7, le=365), ): """ Агрегированные показатели безопасности. Категоризация рисков по severity — без раскрытия деталей эксплуатанта. Соответствует требованиям ГПБП и Annex 19 Safety Management. """ cutoff = datetime.now(timezone.utc) - timedelta(days=days) # Risk distribution by severity severity_dist = db.query( RiskAlert.severity, func.count(RiskAlert.id), ).filter(RiskAlert.created_at >= cutoff).group_by(RiskAlert.severity).all() # Risk trend by month monthly = db.query( func.date_trunc("month", RiskAlert.created_at).label("month"), func.count(RiskAlert.id).label("count"), ).filter(RiskAlert.created_at >= cutoff).group_by("month").order_by("month").all() # Unresolved critical risks count critical_open = db.query(func.count(RiskAlert.id)).filter( RiskAlert.severity == "critical", RiskAlert.resolved == False, ).scalar() or 0 return { "period_days": days, "legal_basis": "ВК РФ ст. 24.1 (ГПБП); ICAO Annex 19; ICAO Doc 9859", "severity_distribution": {s: c for s, c in severity_dist}, "monthly_trend": [ {"month": m.isoformat() if m else None, "count": c} for m, c in monthly ], "critical_unresolved": critical_open, } # ----------------------------------------------------------------------- # 5. АУДИТЫ И ИНСПЕКЦИИ (Audit & Inspection Results) # ВК РФ ст. 28: инспектирование ГА # ICAO Doc 9734 (Safety Oversight Manual): CE-7, CE-8 # EASA Part-ARO.GEN.300: oversight programme # ----------------------------------------------------------------------- @router.get("/audits", dependencies=[FAVT_ROLES]) def audit_results( db: Session = Depends(get_db), days: int = Query(90, ge=7, le=365), page: int = Query(1, ge=1), per_page: int = Query(50, le=200), ): """ Результаты аудитов и чек-листов. Раскрываются: дата, тип, результат (pass/fail/open). НЕ раскрываются: имена инспекторов, детальные замечания. """ cutoff = datetime.now(timezone.utc) - timedelta(days=days) q = db.query(Audit).filter(Audit.created_at >= cutoff) total = q.count() items = q.order_by(Audit.created_at.desc()).offset( (page - 1) * per_page ).limit(per_page).all() return { "total": total, "period_days": days, "legal_basis": "ВК РФ ст. 28; ICAO Doc 9734 CE-7, CE-8; EASA Part-ARO.GEN.300", "items": [ { "id": str(a.id), "type": a.checklist_type if hasattr(a, 'checklist_type') else "standard", "status": a.status if hasattr(a, 'status') else "completed", "aircraft_reg": a.aircraft.registration_number if hasattr(a, 'aircraft') and a.aircraft else None, "conducted_at": a.created_at.isoformat() if a.created_at else None, } for a in items ], } # ----------------------------------------------------------------------- # 6. ОТЧЁТ ДЛЯ ФАВТ (Exportable Report) # Консолидированный отчёт в формате, готовом для загрузки # ----------------------------------------------------------------------- @router.get("/report", dependencies=[FAVT_ROLES]) def generate_report( db: Session = Depends(get_db), user=Depends(get_current_user), ): """Консолидированный отчёт для ФАВТ — все разделы в одном JSON.""" from app.api.helpers import audit overview = regulator_overview(db) safety = safety_indicators(db) audit(db, user, "regulator_report", "system", description="Сформирован отчёт для ФАВТ") db.commit() return { "report_type": "ФАВТ oversight report", "generated_at": datetime.now(timezone.utc).isoformat(), "generated_by": user.display_name, "legal_basis": [ "ВК РФ ст. 8, 24.1, 28, 33, 36, 37, 67, 68", "ФАП-246, ФАП-285", "ФГИС РЭВС (приказ Росавиации № 180-П)", "ICAO Annex 6, 7, 8, 19", "ICAO Doc 9734, Doc 9760, Doc 9859", "EASA Part-M, Part-CAMO, Part-145, Part-ARO", ], "overview": overview, "safety": safety, } # ----------------------------------------------------------------------- # 7. PDF ОТЧЁТ ДЛЯ ФАВТ # Формат, пригодный для приобщения к делу # ----------------------------------------------------------------------- @router.get("/report/pdf", dependencies=[FAVT_ROLES]) def generate_pdf_report( db: Session = Depends(get_db), user=Depends(get_current_user), ): """ Генерация PDF отчёта для ФАВТ. Структура: титульный лист, сводка, реестр ВС, безопасность. """ from io import BytesIO from fastapi.responses import StreamingResponse from app.api.helpers import audit try: from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm from reportlab.pdfgen import canvas from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont except ImportError: return {"error": "reportlab not installed. Install with: pip install reportlab"} buf = BytesIO() c = canvas.Canvas(buf, pagesize=A4) w, h = A4 # Try to register Cyrillic font try: pdfmetrics.registerFont(TTFont("DejaVu", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf")) font = "DejaVu" except Exception: font = "Helvetica" # Title page c.setFont(font, 24) c.drawCentredString(w / 2, h - 80 * mm, "ОТЧЁТ") c.setFont(font, 14) c.drawCentredString(w / 2, h - 95 * mm, "для Федерального агентства воздушного транспорта") c.drawCentredString(w / 2, h - 105 * mm, "(Росавиация)") c.setFont(font, 10) c.drawCentredString(w / 2, h - 125 * mm, f"Дата формирования: {datetime.now(timezone.utc).strftime('%d.%m.%Y %H:%M UTC')}") c.drawCentredString(w / 2, h - 135 * mm, f"Сформировал: {user.display_name}") c.setFont(font, 8) c.drawCentredString(w / 2, h - 160 * mm, "Правовые основания: ВК РФ ст. 8, 24.1, 28, 33, 36, 37, 67, 68;") c.drawCentredString(w / 2, h - 168 * mm, "ФАП-246, ФАП-285; ICAO Annex 6, 7, 8, 19; EASA Part-M, Part-ARO") c.drawCentredString(w / 2, h - 185 * mm, "АСУ ТК КЛГ — АО «REFLY»") c.showPage() # Overview page overview = regulator_overview(db) c.setFont(font, 16) c.drawString(20 * mm, h - 20 * mm, "1. Сводные показатели") c.setFont(font, 10) y = h - 40 * mm sections = [ ("Парк ВС", [ f"Всего: {overview['aircraft']['total']}", f"Годные к полётам: {overview['aircraft']['airworthy']}", f"На ТО: {overview['aircraft']['in_maintenance']}", f"Приостановлены: {overview['aircraft']['grounded']}", f"Списаны: {overview['aircraft']['decommissioned']}", ]), ("Сертификация", [ f"Всего заявок: {overview['certification']['total_applications']}", f"На рассмотрении: {overview['certification']['pending']}", f"Одобрено: {overview['certification']['approved']}", f"Отклонено: {overview['certification']['rejected']}", ]), ("Безопасность полётов", [ f"Всего рисков: {overview['safety']['total_risks']}", f"Критические: {overview['safety']['critical']}", f"Высокие: {overview['safety']['high']}", f"Не устранены: {overview['safety']['unresolved']}", ]), ("Надзор", [ f"Аудитов за 30 дней: {overview['audits_last_30d']}", f"Организации: {overview['organizations']['total']}", ]), ] for title, items in sections: c.setFont(font, 12) c.drawString(20 * mm, y, title) y -= 6 * mm c.setFont(font, 9) for item in items: c.drawString(25 * mm, y, f"• {item}") y -= 5 * mm y -= 4 * mm if y < 30 * mm: c.showPage() y = h - 20 * mm # Footer c.setFont(font, 7) c.drawCentredString(w / 2, 10 * mm, "Документ сформирован автоматически. Персональные данные не раскрываются.") c.showPage() c.save() buf.seek(0) audit(db, user, "regulator_pdf_report", "system", description="Сформирован PDF отчёт для ФАВТ") db.commit() filename = f"favt_report_{datetime.now(timezone.utc).strftime('%Y%m%d')}.pdf" return StreamingResponse( buf, media_type="application/pdf", headers={"Content-Disposition": f"attachment; filename={filename}"}, ) # ----------------------------------------------------------------------- # 8. ПЕРСОНАЛ ПЛГ — сводка для регулятора # ВК РФ ст. 52-54; ФАП-147; ICAO Annex 1 # ----------------------------------------------------------------------- @router.get("/personnel-summary", dependencies=[FAVT_ROLES]) def personnel_summary(): """ Агрегированные данные о персонале ПЛГ для ФАВТ. Показываются: количество специалистов, категории, compliance. НЕ показываются: ФИО, табельные номера, персональные данные. """ from app.api.routes.personnel_plg import _specialists, _qualifications from datetime import datetime, timezone, timedelta now = datetime.now(timezone.utc) total = len(_specialists) by_category = {} compliant = 0 non_compliant = 0 for sid, spec in _specialists.items(): cat = spec.get("category", "?") by_category[cat] = by_category.get(cat, 0) + 1 quals = [q for q in _qualifications.values() if q["specialist_id"] == sid] is_ok = True for q in quals: if q.get("next_due"): due = datetime.fromisoformat(q["next_due"]) if due.replace(tzinfo=timezone.utc) < now: is_ok = False if is_ok: compliant += 1 else: non_compliant += 1 return { "legal_basis": "ВК РФ ст. 52-54; ФАП-147; ICAO Annex 1", "total_specialists": total, "by_category": by_category, "compliant": compliant, "non_compliant": non_compliant, "compliance_rate": round(compliant / total * 100, 1) if total > 0 else 100.0, "note": "ПДн не раскрываются — только агрегированные показатели", } @router.get("/maintenance-summary", dependencies=[FAVT_ROLES]) def maintenance_summary_for_regulator(): """ Агрегированные данные о ТО для ФАВТ. НЕ раскрываются: детали нарядов, ФИО персонала. Правовые основания: ВК РФ ст. 28; ФАП-145; ICAO Doc 9734 CE-7. """ from app.api.routes.work_orders import _work_orders from app.api.routes.defects import _defects wos = list(_work_orders.values()) defs = list(_defects.values()) return { "legal_basis": "ВК РФ ст. 28; ФАП-145; ICAO Doc 9734 CE-7", "work_orders": { "total": len(wos), "in_progress": len([w for w in wos if w["status"] == "in_progress"]), "closed_last_30d": len([w for w in wos if w["status"] == "closed"]), "aog": len([w for w in wos if w.get("priority") == "aog"]), "by_type": {}, }, "defects": { "total": len(defs), "open": len([d for d in defs if d["status"] == "open"]), "deferred_mel": len([d for d in defs if d.get("deferred")]), "critical": len([d for d in defs if d.get("severity") == "critical"]), }, "note": "Детали нарядов и ПДн не раскрываются (ФЗ-152)", }