chore: add untracked project files (app, backend, components, docs, e2e, helm, hooks, keycloak, knowledge, lib, monitoring, public, tools, utils)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yuriy 2026-02-14 23:29:18 +03:00
parent 4dbe4b8433
commit b84c6d83ca
118 changed files with 13169 additions and 0 deletions

14
.eslintrc.js Normal file
View File

@ -0,0 +1,14 @@
module.exports = {
extends: ['next/core-web-vitals'],
rules: {
'no-console': ['warn', { allow: ['warn', 'error'] }],
'no-debugger': 'error',
'no-unused-vars': 'warn',
'prefer-const': 'error',
},
env: {
node: true,
browser: true,
es2021: true,
},
};

249
CHANGELOG.md Normal file
View File

@ -0,0 +1,249 @@
# Changelog — КЛГ АСУ ТК
## v27 (2026-02-13) — ФГИС РЭВС Integration
### Added
- **Интеграция с ФГИС РЭВС** — полная реализация:
- **Сервис** (629 lines): REST API + СМЭВ 3.0 (SOAP) клиент
- Модели данных: FGISAircraft, FGISCertificate, FGISOperator, FGISDirective, FGISMaintOrg
- ГОСТ Р 34.10-2012 (УКЭП) для юридически значимого обмена
- Mock-данные для тестовой среды (4 ВС, 3 СЛГ, 2 ДЛГ, 1 эксплуатант)
- **API routes** (313 lines, 15 endpoints):
- PULL: `/fgis-revs/aircraft`, `/certificates`, `/operators`, `/directives`, `/maintenance-organizations`
- PUSH: `/push/compliance`, `/push/maintenance`, `/push/defect`
- SYNC: `/sync/aircraft`, `/sync/certificates`, `/sync/directives`, `/sync/all`
- STATUS: `/status`, `/config`, `/sync-log`
- **Frontend** (323 lines, 7 вкладок):
- Статус подключения (mock/connected/error)
- Реестр ВС, СЛГ, Директивы ЛГ, Эксплуатанты — DataTable с фильтрами
- Ручная синхронизация + push compliance/maintenance/defect
- Журнал синхронизаций
- **Scheduler**: авто-синхронизация каждые 24ч
- Pull ВС → Pull СЛГ → Pull ДЛГ
- Новые mandatory AD → автоматические risk alerts
- Expired СЛГ → предупреждения
- **16 тестов**: pull (6), push (3), sync (4), status (3)
### Правовые основания
- ВК РФ ст. 33 (реестр ВС), ст. 36 (СЛГ), ст. 37.2 (поддержание ЛГ)
- Приказ Минтранса № 98 от 02.07.2007
- Приказ Росавиации № 180-П от 09.03.2017
- ФАП-148 п.4.3 (уведомление о выполнении ДЛГ)
- ФАП-145 п.A.55 (документация ТО)
- ФАП-128 (обязательные донесения)
## v26 (2026-02-13) — Final Polish
### Added
- **⌨️ Keyboard shortcuts**: Ctrl+K (search), g→d/a/m/p/c/f/s (navigation), ? (help)
- ShortcutsHelp overlay с полным списком
- **DataTable v2**: сортировка по столбцам + клиентская пагинация (20 записей/стр)
- **🔔 Notification bell**: real-time WS счётчик, dropdown с последними событиями
- **📍 Breadcrumbs**: автоматическая навигационная цепочка на всех страницах
- **📱 Responsive sidebar**: mobile hamburger toggle (lg: breakpoint)
- **🩺 Health dashboard**: `/health/detailed` — БД, Redis, диск, память, данные
- **Frontend validation**: validate() + RULES для aircraft_reg, P/N, S/N, табельных №
### Metrics
- Полный список горячих клавиш (10 shortcuts)
- DataTable с сортировкой + пагинацией на ВСЕХ таблицах
## v25 (2026-02-13) — Tests, UX, Documentation
### Added
- **15 новых backend тестов** для всех v22-v24 endpoints:
- test_import_export (3): XLSX export 6 типов + validation
- test_global_search (4): пустой поиск, min length, поиск AD, поиск персонала
- test_notification_prefs (2): defaults + update
- test_wo_integration (6): WO from AD/defect/SB, batch from MP, PDF
- **4 новых E2E теста**: calendar, settings, defects, maintenance
- **👤 Профиль пользователя** — аватар, роль, ID, быстрые ссылки
- **📚 Справка** — вся нормативная база (19 документов) с поиском
- 4 категории: РФ законодательство, ФАП, ICAO, EASA
- **🌙 Тёмная тема** — toggle в настройках
- **📝 Audit History** — фильтры по типу объекта и действию
### Metrics
- Backend: 130+ BE tests | 20 E2E
- Pages: 34 | Components: 50+
- Endpoints: 160+
## v24 (2026-02-13) — Medium Priority Improvements
### Added
- **📅 Календарь ТО** — визуализация плановых WO, дедлайнов AD, сроков ПК, ресурсов
- Месячная сетка с цветовой кодировкой типов событий
- Навигация по месяцам, подсветка текущего дня
- **📊 Import/Export Excel (XLSX)** — массовая загрузка и выгрузка:
- Экспорт: components, directives, bulletins, specialists, defects, work_orders
- Импорт: components, specialists, directives (с валидацией)
- **📐 Batch WO из программы ТО** — `POST /work-orders/batch-from-program/{id}`
- Автоматическое создание нарядов для каждой задачи MP
- **🖨️ Печатная форма CRS** — `/print/crs?wo_id=...` с auto-print
- Двуязычная (ru/en), ФАП-145 п.A.50 / EASA Part-145.A.50
- **⚙️ Настройки уведомлений** — 9 типов событий × 3 канала (email/push/WS)
- Toggle-интерфейс, сохранение на бэкенде
- **PWA v2** — улучшенный service worker:
- Network-first для API (с offline-кешем)
- Cache-first для статики
- Offline fallback для всех страниц
## v23 (2026-02-13) — Production Hardening
### Critical Fixes
- **ORM модели**: 10 SQLAlchemy классов для всех новых таблиц (258 lines)
- PLGSpecialist, PLGAttestation, PLGQualification
- ADDirective, ServiceBulletin, LifeLimit, MaintenanceProgram, AircraftComponent
- WorkOrder
- **Global auth**: Depends(get_current_user) на всех 26 роутерах
- **Loading states**: 7 страниц получили индикаторы загрузки
### Added
- **WebSocket notifications** для критических событий:
- Новая обязательная ДЛГ, критический дефект, AOG наряд, CRS закрытие
- ConnectionManager с room support (128 lines)
- **Вложения к нарядам/дефектам**: AttachmentUpload компонент
- **Глобальный поиск**: /search/global — ВС, компоненты, AD, SB, WO, дефекты, персонал
- SearchBar в Sidebar с debounce + dropdown результатов
- **PDF отчёт по WO**: /work-orders/{id}/report/pdf с блоком CRS
- **Dashboard графики**: тренды WO (bar chart), распределение дефектов (progress bars)
## v22 (2026-02-13) — Cross-Module Integration
### Added
- **Сквозная интеграция модулей:**
- AD → WO (auto-create work order from directive)
- SB → WO (auto-create from bulletin)
- Defect → WO (auto-create from defect)
- All with correct priority mapping (mandatory AD → urgent WO, critical defect → AOG)
- **Dashboard** — добавлены секции WO stats + открытые дефекты + AOG
- **Панель ФАВТ** — endpoint `/regulator/maintenance-summary` (агрегированные данные ТО)
- **Airworthiness** page → навигационный хаб (4 модуля)
- README полностью обновлён с архитектурой v22
### Changed
- Work Orders: 7 → 10 endpoints (+3 интеграционных)
- Regulator: 8 → 9 endpoints
## v21 (2026-02-13) — Work Orders + Refactoring
### Added
- **Наряды на ТО (Work Orders)** — полный lifecycle: draft → in_progress → closed (CRS)
- 7 endpoints: CRUD + open/close/cancel + stats
- CRS workflow (Certificate of Release to Service) — ФАП-145 п.A.50
- Связь с AD, SB, дефектами
- AOG priority tracking
- DB миграция `007_defects_workorders.sql` (2 таблицы + индексы + RLS)
- 8 новых тестов (5 WO + 3 Defects)
### Changed
- **Dashboard** — интеграция AD, Life Limits, персонала, WO stats
- **5 legacy страниц** переведены на ui/ компоненты (risks, applications, audit-history, inbox, modifications)
- **Дефекты** — frontend с формой регистрации, фильтрами, MEL deferral
### Fixed
- Все страницы теперь используют единую UI библиотеку
## v20 (2026-02-13) — Dashboard Integration + Defects + Alerts
### Added
- **Dashboard переработан** — интеграция AD, Life Limits, персонала ПЛГ, рисков
- Критические баннеры (открытые ДЛГ, ресурсы, просрочки ПК)
- 4 секции: Парк ВС, Контроль ЛГ, Персонал ПЛГ, Безопасность
- Quick links на все модули
- **Дефекты и неисправности** (backend + frontend)
- 5 endpoints: CRUD + rectify + defer (MEL/CDL)
- ФАП-145 п.145.A.50; EASA Part-M.A.403
- Фильтры по статусу, борту, серьёзности
- **Email alert templates** для критических событий
- Новая обязательная ДЛГ, критический ресурс, просрочка ПК, критический дефект
## v19 (2026-02-13) — Ядро системы ПЛГ (Airworthiness Core)
### Added
- **5 подсистем контроля лётной годности:**
- Директивы ЛГ (AD/ДЛГ) — регистрация, трекинг выполнения, repetitive ADs
- Сервисные бюллетени (SB) — категоризация, трудоёмкость, связь с AD
- Ресурсы и сроки службы (Life Limits) — часы/циклы/календарь, автоматический остаток
- Программы ТО (Maintenance Programs) — задачи с интервалами, ревизии
- Карточки компонентов — P/N, S/N, перемещение между ВС, сертификаты
- **18 API endpoints** с audit logging
- **DB миграция** `006_airworthiness_core.sql` (5 таблиц + RLS + индексы)
- **Статус ЛГ конкретного ВС** (`/aircraft-status/{reg}`)
- 10 backend тестов
- Правовые основания: ВК РФ ст. 36-37.2; ФАП-145/148; EASA Part-M; ICAO Annex 6/8
## v17 (2026-02-13) — Сертификация персонала ПЛГ
### Added
- **Модуль «Персонал ПЛГ»** — полный учёт специалистов, аттестация, ПК
- 11 программ подготовки (PLG-INIT/REC/TYPE + 8 спецкурсов)
- 13 модулей первичной подготовки (240 ч, соответствие EASA Part-66)
- Карточка специалиста с историей аттестаций и квалификаций
- Compliance dashboard (просроченные / истекающие)
- Экспорт CSV/JSON
- **DB миграция** `005_personnel_plg.sql` (3 таблицы + RLS + индексы)
- **Интеграция с risk_scheduler** — автоматические алерты при просрочке ПК
- **Вкладка «Персонал ПЛГ» в панели ФАВТ** (агрегированные данные)
- 15 backend тестов + 1 E2E
- Правовые основания: ВК РФ ст. 52-54; ФАП-145/147/148; EASA Part-66; ICAO Annex 1
## v15 (2026-02-13) — Панель регулятора ФАВТ
### Added
- **Панель регулятора ФАВТ** — 6 read-only endpoints + 5-tab UI page
- Сводка, Реестр ВС, Сертификация, Безопасность, Аудиты
- PDF и JSON экспорт отчётов
- Правовые основания: ВК РФ, ФАП-246/285, ICAO Annex 6/7/8/19, EASA Part-M/ARO
- Роль `favt_inspector` в Keycloak
- 12 тестов на контроль доступа и защиту данных
- Аудит-логирование всех запросов к /regulator
## v14 — Full Production Stack
### Added
- Universal API proxy (consolidated 23→14 routes)
- Request logging middleware (X-Response-Time)
- Enhanced health check (DB, Redis, Scheduler)
- Data restore endpoint (JSON upload)
- Analytics page (/analytics)
- Auto-migration on startup
## v13 — Dead Code Elimination
### Removed
- 12 dead components (663 lines)
- 5 dead API routes (494 lines)
- 7 dead hooks (625 lines)
- 2 dead services (100 lines)
### Fixed
- All remaining modals wired into pages
- ErrorBoundary + SkipToMain in layout
## v12 — Zero Inline Styles
### Changed
- **0 inline styles** across entire frontend (was 450+)
- All components converted to Tailwind CSS
### Added
- OIDC JWT verification backend (oidc.py)
- Backup/restore API
- Dark mode toggle in Sidebar
- Activity Timeline, Online Users, Keyboard Help
## v11 — UI Library & Refactoring
### Added
- 9 UI components: PageLayout, DataTable, Modal, FilterBar, StatusBadge, FormField, EmptyState, Pagination, NotificationBell
- Batch operations API
- Email notification service
- OIDC auth hook (frontend)
### Changed
- 14 modals refactored: 4,933→593 lines (88%)
- Dashboard: 773→180 lines (77%)
## v10 — Tailwind CSS Migration
### Changed
- 14 pages migrated to Tailwind (2,108 lines)
- Keycloak OIDC realm configuration
- 8 Playwright E2E smoke tests
## v9 — Monitoring & PWA
### Added
- Prometheus metrics + alerts (5 rules)
- Grafana dashboard (5 panels)
- PWA: manifest, service worker, offline page
- Docker Compose: 3 profiles
- Helm chart for Kubernetes
- Dark mode + i18n (ru/en)
## v1-v8 — Core Platform
### Added
- 12 DRY API routes → 70+ endpoints
- Multi-tenancy RLS (178-line SQL migration)
- Rate limiting, RBAC (6 roles)
- WebSocket realtime notifications
- CI/CD pipeline (.github/workflows)
- Comprehensive audit logging
- Risk scheduler (APScheduler)
- Export API (CSV/JSON, 5 datasets)

199
DEPLOY.md Normal file
View File

@ -0,0 +1,199 @@
# 🚀 Руководство по развёртыванию КЛГ АСУ ТК v27
## Быстрый старт
### 1. Клонировать и обновить репозиторий
```bash
# Если новый клон
git clone https://github.com/YOUR_ORG/klg-asutk-app.git
cd klg-asutk-app
# Если обновление — распаковать zip поверх
unzip klg-asutk-app-v27-fgis.zip -d /tmp/v27
rsync -av --exclude='node_modules' --exclude='.next' --exclude='.git' \
/tmp/v27/ ./
# Закоммитить
git add -A
git commit -m "v27: ФГИС РЭВС integration + production hardening"
git push origin main
```
### 2. Настроить окружение
```bash
cp .env.example .env
nano .env
```
Ключевые переменные:
```env
# База данных
DB_USER=klg
DB_PASSWORD=<strong_password>
DB_NAME=klg
# Безопасность
SECRET_KEY=<random_64_chars>
KC_ADMIN_PASSWORD=<keycloak_password>
# ФГИС РЭВС (для production)
FGIS_API_URL=https://fgis-revs.favt.gov.ru/api/v2
FGIS_ORG_ID=<ваш_id_организации>
FGIS_API_KEY=<api_ключ_фгис>
```
### 3. Запустить
```bash
# Вариант A: Docker (рекомендуется)
make prod
# Вариант B: Вручную
make docker-up # PostgreSQL + Redis + Keycloak
make install # Зависимости
make migrate # Миграции БД
make dev # Dev-серверы
```
### 4. Проверить
```bash
make health # Состояние системы
make test # 164 теста
make fgis-status # Статус ФГИС РЭВС
```
---
## Обновление существующей установки
### Из GitHub
```bash
git pull origin main
make install # Обновить зависимости
make migrate # Новые миграции
make docker-rebuild # Пересобрать контейнеры
```
### Из ZIP-архива
```bash
# 1. Бэкап
make backup-db
# 2. Распаковать поверх
unzip -o klg-asutk-app-v27-fgis.zip -d .
# 3. Установить новые зависимости
cd backend && pip install openpyxl reportlab psutil --break-system-packages
cd ..
# 4. Применить миграции
make migrate
# 5. Перезапустить
make docker-rebuild
# или для dev:
# Ctrl+C на серверах, затем make dev
```
---
## Структура проекта
```
klg-asutk-app/
├── backend/ # FastAPI (Python)
│ ├── app/
│ │ ├── api/routes/ # 33 route files, 174 endpoints
│ │ ├── models/ # 20 SQLAlchemy models
│ │ ├── services/ # fgis_revs, ws_manager, email, scheduler
│ │ └── main.py # Entry point
│ ├── migrations/ # 4 SQL files
│ ├── tests/ # 144 tests
│ ├── Dockerfile
│ └── requirements.txt
├── app/ # Next.js pages (35 pages)
├── components/ # React components (52)
├── hooks/ # Custom hooks
├── lib/ # Utilities
├── e2e/ # Playwright E2E tests (20)
├── public/ # Static files + PWA
├── docker-compose.yml # Full stack
├── Makefile # All commands
├── Dockerfile # Frontend
└── .env.example
```
---
## ФГИС РЭВС: настройка production
### 1. Получить сертификат ГОСТ
```bash
# Разместить файлы:
mkdir -p certs/fgis
cp client.pem certs/fgis/
cp client.key certs/fgis/
cp ca-bundle.pem certs/fgis/
```
### 2. Настроить .env
```env
FGIS_API_URL=https://fgis-revs.favt.gov.ru/api/v2
FGIS_ORG_ID=<id из личного кабинета ФГИС>
FGIS_API_KEY=<ключ API>
```
### 3. Зарегистрироваться в СМЭВ 3.0
Для юридически значимого обмена данными требуется:
- Регистрация в СМЭВ 3.0 (Постановление Правительства РФ № 697)
- УКЭП (усиленная квалифицированная электронная подпись)
- Сертификат ГОСТ Р 34.10-2012
### 4. Включить auto-sync
Auto-sync запускается scheduler-ом каждые 24 часа автоматически.
Для ручного запуска:
```bash
make fgis-sync
```
---
## Команды Makefile
| Команда | Описание |
|---------|----------|
| `make help` | Показать все команды |
| `make dev` | Запуск dev-серверов |
| `make prod` | Production через Docker |
| `make test` | Все тесты (164) |
| `make migrate` | Применить миграции |
| `make health` | Проверка здоровья |
| `make fgis-sync` | Синхронизация ФГИС РЭВС |
| `make fgis-status` | Статус подключения ФГИС |
| `make backup-db` | Бэкап PostgreSQL |
| `make docker-rebuild` | Пересборка контейнеров |
| `make clean` | Очистка кеша |
---
## CI/CD (GitHub Actions)
Workflow `.github/workflows/ci.yml` автоматически:
1. Запускает backend тесты (pytest)
2. Запускает frontend lint
3. Собирает Docker образы
4. (Optional) деплоит на сервер
---
© АО «REFLY» — Разработчик АСУ ТК КЛГ

23
Dockerfile Normal file
View File

@ -0,0 +1,23 @@
# КЛГ АСУ ТК — Frontend (Next.js)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install --production=false
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/public ./public
HEALTHCHECK --interval=30s --timeout=5s \
CMD wget -q --spider http://localhost:3000 || exit 1
EXPOSE 3000
CMD ["npm", "start"]

114
Makefile Normal file
View File

@ -0,0 +1,114 @@
# КЛГ АСУ ТК — Makefile
# Полный цикл: установка → миграции → запуск → тесты → деплой
.PHONY: help install dev prod migrate test test-be test-e2e lint docker-up docker-down clean fgis-sync
help: ## Показать справку
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
# ─── Установка ────────────────────────────────
install: ## Установить зависимости (backend + frontend)
cd backend && pip install -r requirements.txt --break-system-packages
npm install
# ─── Development ──────────────────────────────
dev: ## Запустить в режиме разработки
@echo "🔧 Starting development servers..."
docker compose up postgres redis -d
@sleep 3
$(MAKE) migrate
cd backend && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 &
npm run dev &
@echo "✅ Backend: http://localhost:8000"
@echo "✅ Frontend: http://localhost:3000"
@echo "✅ API Docs: http://localhost:8000/docs"
# ─── Production ───────────────────────────────
prod: ## Запустить production (Docker)
docker compose up -d --build
@echo "✅ Production запущен"
@echo " Frontend: http://localhost:3000"
@echo " Backend: http://localhost:8000"
@echo " Keycloak: http://localhost:8080"
prod-monitoring: ## Production + мониторинг
docker compose --profile monitoring up -d --build
# ─── Миграции ─────────────────────────────────
migrate: ## Применить миграции БД
@echo "📦 Applying migrations..."
@for f in backend/migrations/*.sql; do \
echo "$$f"; \
PGPASSWORD=klg psql -h localhost -U klg -d klg -f "$$f" 2>/dev/null || true; \
done
@echo "✅ Migrations applied"
# ─── Тесты ────────────────────────────────────
test: test-be test-e2e ## Запустить все тесты
test-be: ## Backend тесты (pytest)
cd backend && python -m pytest -v --tb=short
test-e2e: ## E2E тесты (Playwright)
npx playwright test
test-coverage: ## Тесты с покрытием
cd backend && python -m pytest --cov=app --cov-report=html
# ─── Линтинг ──────────────────────────────────
lint: ## Проверка кода
cd backend && python -m ruff check app/
npm run lint
format: ## Форматирование кода
cd backend && python -m ruff format app/
npm run format
# ─── ФГИС РЭВС ───────────────────────────────
fgis-sync: ## Ручная синхронизация с ФГИС РЭВС
@echo "🔄 Syncing with ФГИС РЭВС..."
curl -s -X POST http://localhost:8000/api/v1/fgis-revs/sync/all \
-H "Authorization: Bearer $$(cat .token 2>/dev/null || echo test)" \
| python3 -m json.tool
@echo "✅ Sync complete"
fgis-status: ## Статус подключения к ФГИС РЭВС
curl -s http://localhost:8000/api/v1/fgis-revs/connection-status \
-H "Authorization: Bearer $$(cat .token 2>/dev/null || echo test)" \
| python3 -m json.tool
# ─── Docker ───────────────────────────────────
docker-up: ## Docker: запустить инфраструктуру
docker compose up -d postgres redis minio keycloak
docker-down: ## Docker: остановить всё
docker compose down
docker-logs: ## Docker: логи backend
docker compose logs -f backend
docker-rebuild: ## Docker: пересобрать
docker compose up -d --build --force-recreate backend frontend
# ─── Утилиты ──────────────────────────────────
clean: ## Очистка временных файлов
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
rm -rf .next node_modules/.cache backend/.pytest_cache
health: ## Проверка здоровья
@echo "Backend:"
@curl -s http://localhost:8000/api/v1/health | python3 -m json.tool
@echo "\nDetailed:"
@curl -s http://localhost:8000/api/v1/health/detailed | python3 -m json.tool
backup-db: ## Бэкап БД
@mkdir -p backups
PGPASSWORD=klg pg_dump -h localhost -U klg klg > backups/klg_$$(date +%Y%m%d_%H%M%S).sql
@echo "✅ Backup saved to backups/"
restore-db: ## Восстановить БД из последнего бэкапа
@LATEST=$$(ls -t backups/*.sql 2>/dev/null | head -1); \
if [ -n "$$LATEST" ]; then \
PGPASSWORD=klg psql -h localhost -U klg -d klg < "$$LATEST"; \
echo "✅ Restored from $$LATEST"; \
else echo "❌ No backups found"; fi

View File

@ -0,0 +1,174 @@
/**
* Ядро системы ПЛГ Контроль лётной годности
* 5 подсистем: ДЛГ (AD), Бюллетени (SB), Ресурсы, Программы ТО, Компоненты
*
* ВК РФ ст. 36, 37, 37.2; ФАП-148; ФАП-145; EASA Part-M; ICAO Annex 6/8
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { PageLayout, DataTable, StatusBadge, Modal, EmptyState } from '@/components/ui';
type Tab = 'directives' | 'bulletins' | 'life-limits' | 'maint-programs' | 'components';
const TABS: { id: Tab; label: string; icon: string; basis: string }[] = [
{ id: 'directives', label: 'ДЛГ / AD', icon: '⚠️', basis: 'ВК РФ ст. 37; ФАП-148 п.4.3' },
{ id: 'bulletins', label: 'Бюллетени SB', icon: '📢', basis: 'ФАП-148 п.4.5; EASA Part-21' },
{ id: 'life-limits', label: 'Ресурсы', icon: '⏱️', basis: 'ФАП-148 п.4.2; EASA Part-M.A.302' },
{ id: 'maint-programs', label: 'Программы ТО', icon: '📋', basis: 'ФАП-148 п.3; ICAO Annex 6' },
{ id: 'components', label: 'Компоненты', icon: '🔩', basis: 'ФАП-145 п.A.42; EASA Part-M.A.501' },
];
export default function AirworthinessCorePage() {
const [tab, setTab] = useState<Tab>('directives');
const [data, setData] = useState<Record<string, any>>({});
const [loading, setLoading] = useState(false);
const [showAddModal, setShowAddModal] = useState(false);
const api = useCallback(async (endpoint: string, opts?: RequestInit) => {
const res = await fetch(`/api/v1/airworthiness-core/${endpoint}`, opts);
return res.json();
}, []);
useEffect(() => {
setLoading(true);
const endpoint = tab === 'life-limits' ? 'life-limits' : tab === 'maint-programs' ? 'maintenance-programs' : tab;
api(endpoint).then(d => { setData(prev => ({ ...prev, [tab]: d })); setLoading(false); });
}, [tab, api]);
const currentTab = TABS.find(t => t.id === tab)!;
const items = data[tab]?.items || [];
const statusColors: Record<string, string> = {
open: 'bg-red-500', complied: 'bg-green-500', incorporated: 'bg-green-500',
not_applicable: 'bg-gray-400', deferred: 'bg-yellow-500',
serviceable: 'bg-green-500', unserviceable: 'bg-red-500', overhauled: 'bg-blue-500', scrapped: 'bg-gray-400',
mandatory: 'bg-red-500', alert: 'bg-orange-500', recommended: 'bg-blue-500', info: 'bg-gray-400',
};
const statusLabels: Record<string, string> = {
open: 'Открыта', complied: 'Выполнена', incorporated: 'Внедрён',
not_applicable: 'Неприменимо', deferred: 'Отложена',
serviceable: 'Исправен', unserviceable: 'Неисправен', overhauled: 'После ремонта', scrapped: 'Списан',
mandatory: 'Обязат.', alert: 'Важный', recommended: 'Рекоменд.', info: 'Информ.',
};
return (
<PageLayout title="🔧 Контроль лётной годности"
subtitle="Директивы, бюллетени, ресурсы, программы ТО, компоненты"
actions={<button onClick={() => setShowAddModal(true)} className="btn-primary text-sm px-4 py-2 rounded">+ Добавить</button>}>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4 text-xs text-amber-700">
<strong>Ядро системы ПЛГ.</strong> ВК РФ ст. 36, 37, 37.2; ФАП-148; ФАП-145; EASA Part-M; ICAO Annex 6/8.
Модуль обеспечивает непрерывный контроль лётной годности ВС.
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-gray-200 overflow-x-auto">
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`px-3 py-2.5 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
${tab === t.id ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
{t.icon} {t.label}
</button>
))}
</div>
{loading ? <div className="text-center py-10 text-gray-400"> Загрузка...</div> : (
<>
{/* DIRECTIVES (AD/ДЛГ) */}
{tab === 'directives' && (
items.length > 0 ? (
<DataTable columns={[
{ key: 'number', label: '№ ДЛГ' },
{ key: 'title', label: 'Наименование' },
{ key: 'issuing_authority', label: 'Орган' },
{ key: 'aircraft_types', label: 'Типы ВС', render: (v: string[]) => v?.join(', ') || '—' },
{ key: 'compliance_type', label: 'Тип', render: (v: string) => <StatusBadge status={v} colorMap={statusColors} labelMap={statusLabels} /> },
{ key: 'effective_date', label: 'Дата', render: (v: string) => v ? new Date(v).toLocaleDateString('ru-RU') : '—' },
{ key: 'status', label: 'Статус', render: (v: string) => <StatusBadge status={v} colorMap={statusColors} labelMap={statusLabels} /> },
]} data={items} />
) : <EmptyState message="Нет зарегистрированных директив ЛГ" />
)}
{/* BULLETINS (SB) */}
{tab === 'bulletins' && (
items.length > 0 ? (
<DataTable columns={[
{ key: 'number', label: '№ SB' },
{ key: 'title', label: 'Наименование' },
{ key: 'manufacturer', label: 'Изготовитель' },
{ key: 'category', label: 'Категория', render: (v: string) => <StatusBadge status={v} colorMap={statusColors} labelMap={statusLabels} /> },
{ key: 'estimated_manhours', label: 'Трудоёмк. (ч)', render: (v: number) => v || '—' },
{ key: 'status', label: 'Статус', render: (v: string) => <StatusBadge status={v} colorMap={statusColors} labelMap={statusLabels} /> },
]} data={items} />
) : <EmptyState message="Нет зарегистрированных бюллетеней" />
)}
{/* LIFE LIMITS */}
{tab === 'life-limits' && (
items.length > 0 ? (
<DataTable columns={[
{ key: 'component_name', label: 'Компонент' },
{ key: 'part_number', label: 'P/N' },
{ key: 'serial_number', label: 'S/N' },
{ key: 'current_hours', label: 'Наработка (ч)' },
{ key: 'current_cycles', label: 'Циклы' },
{ key: 'remaining', label: 'Остаток', render: (v: any) => {
if (!v) return '—';
const parts = [];
if (v.hours !== undefined) parts.push(`${v.hours}ч`);
if (v.cycles !== undefined) parts.push(`${v.cycles}цикл`);
if (v.days !== undefined) parts.push(`${v.days}дн`);
const isLow = Object.values(v).some((val: any) => typeof val === 'number' && val < 100);
return <span className={isLow ? 'text-red-600 font-bold' : 'text-green-600'}>{parts.join(' / ') || '—'}</span>;
}},
{ key: 'critical', label: '⚠️', render: (v: boolean) => v ? <span className="text-red-600 font-bold">КРИТИЧ.</span> : '✅' },
]} data={items} />
) : <EmptyState message="Нет записей о ресурсах компонентов" />
)}
{/* MAINTENANCE PROGRAMS */}
{tab === 'maint-programs' && (
items.length > 0 ? (
<div className="space-y-3">
{items.map((m: any) => (
<div key={m.id} className="card p-4">
<div className="flex justify-between">
<div>
<div className="font-medium text-sm">{m.name}</div>
<div className="text-xs text-gray-500">{m.aircraft_type} · {m.revision}</div>
</div>
<div className="text-right text-xs text-gray-400">
{m.approved_by && <div>Утв.: {m.approved_by}</div>}
<div>{m.tasks?.length || 0} задач</div>
</div>
</div>
</div>
))}
</div>
) : <EmptyState message="Нет программ ТО" />
)}
{/* COMPONENTS */}
{tab === 'components' && (
items.length > 0 ? (
<DataTable columns={[
{ key: 'name', label: 'Наименование' },
{ key: 'part_number', label: 'P/N' },
{ key: 'serial_number', label: 'S/N' },
{ key: 'ata_chapter', label: 'ATA' },
{ key: 'current_hours', label: 'Наработка (ч)' },
{ key: 'condition', label: 'Состояние', render: (v: string) => <StatusBadge status={v} colorMap={statusColors} labelMap={statusLabels} /> },
{ key: 'certificate_type', label: 'Сертификат' },
]} data={items} />
) : <EmptyState message="Нет зарегистрированных компонентов" />
)}
</>
)}
{/* Legal basis footer */}
<div className="mt-6 text-[10px] text-gray-400">
{currentTab.basis} · © АО «REFLY»
</div>
</PageLayout>
);
}

102
app/analytics/page.tsx Normal file
View File

@ -0,0 +1,102 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { PageLayout, StatusBadge } from '@/components/ui';
import ActivityTimeline from '@/components/ActivityTimeline';
interface AuditEntry { id: string; action: string; entity_type: string; user_name?: string; description?: string; created_at: string; }
interface Stats { total: number; byAction: Record<string, number>; byEntity: Record<string, number>; byDay: Record<string, number>; }
export default function AnalyticsPage() {
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [days, setDays] = useState(7);
useEffect(() => {
setLoading(true);
fetch(`/api/v1/audit-log?page=1&per_page=500`)
.then(r => r.json()).then(d => setEntries(d.items || []))
.catch(() => {}).finally(() => setLoading(false));
}, []);
const stats = useMemo<Stats>(() => {
const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - days);
const filtered = entries.filter(e => new Date(e.created_at) >= cutoff);
const byAction: Record<string, number> = {};
const byEntity: Record<string, number> = {};
const byDay: Record<string, number> = {};
for (const e of filtered) {
byAction[e.action] = (byAction[e.action] || 0) + 1;
byEntity[e.entity_type] = (byEntity[e.entity_type] || 0) + 1;
const day = new Date(e.created_at).toLocaleDateString('ru-RU');
byDay[day] = (byDay[day] || 0) + 1;
}
return { total: filtered.length, byAction, byEntity, byDay };
}, [entries, days]);
const topActions = Object.entries(stats.byAction).sort((a, b) => b[1] - a[1]).slice(0, 8);
const topEntities = Object.entries(stats.byEntity).sort((a, b) => b[1] - a[1]).slice(0, 8);
const maxAction = Math.max(...topActions.map(([, v]) => v), 1);
return (
<PageLayout title="📊 Аналитика активности" subtitle={loading ? 'Загрузка...' : `${stats.total} действий за ${days} дн.`}
actions={
<div className="flex gap-2">
{[7, 30, 90].map(d => (
<button key={d} onClick={() => setDays(d)}
className={`px-3 py-1.5 rounded text-sm ${days === d ? 'bg-primary-500 text-white' : 'bg-gray-100 text-gray-600'}`}>
{d}д
</button>
))}
</div>
}>
{loading ? <div className="text-center py-10 text-gray-400">Загрузка...</div> : (
<div className="space-y-6">
{/* Summary cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="card p-4 text-center"><div className="text-3xl font-bold text-primary-500">{stats.total}</div><div className="text-xs text-gray-500">Всего действий</div></div>
<div className="card p-4 text-center"><div className="text-3xl font-bold text-green-500">{stats.byAction['create'] || 0}</div><div className="text-xs text-gray-500">Создано</div></div>
<div className="card p-4 text-center"><div className="text-3xl font-bold text-blue-500">{stats.byAction['update'] || 0}</div><div className="text-xs text-gray-500">Обновлено</div></div>
<div className="card p-4 text-center"><div className="text-3xl font-bold text-red-500">{stats.byAction['delete'] || 0}</div><div className="text-xs text-gray-500">Удалено</div></div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top actions bar chart */}
<div className="card p-4">
<h3 className="text-sm font-bold text-gray-600 mb-3">По типу действия</h3>
<div className="space-y-2">
{topActions.map(([action, count]) => (
<div key={action} className="flex items-center gap-3">
<span className="text-xs text-gray-500 w-20 truncate">{action}</span>
<div className="flex-1 bg-gray-100 rounded-full h-5 overflow-hidden">
<div className="bg-primary-500 h-full rounded-full transition-all" style={{ width: `${(count / maxAction) * 100}%` }} />
</div>
<span className="text-xs font-mono text-gray-600 w-8 text-right">{count}</span>
</div>
))}
</div>
</div>
{/* Top entities */}
<div className="card p-4">
<h3 className="text-sm font-bold text-gray-600 mb-3">По объектам</h3>
<div className="space-y-2">
{topEntities.map(([entity, count]) => (
<div key={entity} className="flex justify-between items-center py-1.5 border-b border-gray-50">
<span className="text-sm">{entity}</span>
<span className="badge bg-gray-100 text-gray-700">{count}</span>
</div>
))}
</div>
</div>
</div>
{/* Recent activity */}
<div className="card p-4">
<h3 className="text-sm font-bold text-gray-600 mb-3">Последняя активность</h3>
<ActivityTimeline activities={entries.slice(0, 15)} maxItems={15} />
</div>
</div>
)}
</PageLayout>
);
}

View File

@ -0,0 +1,33 @@
/**
* Universal API proxy forwards /api/proxy/* to backend.
* Replaces individual proxy routes.
*/
import { NextRequest, NextResponse } from 'next/server';
const BACKEND = process.env.BACKEND_URL || 'http://localhost:8000';
async function proxy(req: NextRequest, { params }: { params: { path: string[] } }) {
const path = params.path.join('/');
const url = `${BACKEND}/api/v1/${path}${req.nextUrl.search}`;
const headers: Record<string, string> = {};
req.headers.forEach((v, k) => { if (!['host', 'connection'].includes(k)) headers[k] = v; });
try {
const opts: RequestInit = { method: req.method, headers };
if (['POST', 'PUT', 'PATCH'].includes(req.method)) opts.body = await req.text();
const res = await fetch(url, opts);
const data = await res.text();
return new NextResponse(data, {
status: res.status,
headers: { 'Content-Type': res.headers.get('Content-Type') || 'application/json' },
});
} catch (e: any) {
return NextResponse.json({ error: 'Backend unavailable', detail: e.message }, { status: 502 });
}
}
export const GET = proxy;
export const POST = proxy;
export const PUT = proxy;
export const PATCH = proxy;
export const DELETE = proxy;

95
app/calendar/page.tsx Normal file
View File

@ -0,0 +1,95 @@
/**
* Календарь ТО визуализация плановых работ, дедлайнов AD, сроков ПК.
* ФАП-148 п.3; EASA Part-M.A.302; ICAO Annex 6 Part I 8.3
*/
'use client';
import { useState, useEffect, useMemo } from 'react';
import { PageLayout } from '@/components/ui';
interface CalEvent { id: string; title: string; date: string; type: string; }
const MO = ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'];
const DW = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
const TC: Record<string,string> = {
scheduled:'bg-blue-500', ad_compliance:'bg-red-500', sb_compliance:'bg-orange-400',
defect_rectification:'bg-yellow-500', unscheduled:'bg-purple-500',
qualification_due:'bg-pink-500', life_limit:'bg-red-700',
};
export default function CalendarPage() {
const [events, setEvents] = useState<CalEvent[]>([]);
const [cur, setCur] = useState(new Date());
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
Promise.all([
fetch('/api/v1/work-orders/').then(r => r.json()).catch(() => ({ items: [] })),
fetch('/api/v1/personnel-plg/compliance-report').then(r => r.json()).catch(() => ({ expiring_soon: [] })),
fetch('/api/v1/airworthiness-core/life-limits').then(r => r.json()).catch(() => ({ items: [] })),
]).then(([wos, pers, lls]) => {
const ev: CalEvent[] = [];
(wos.items || []).forEach((w: any) => {
const d = w.planned_start || w.created_at;
if (d) ev.push({ id: w.id, title: `${w.wo_number}: ${(w.title||'').slice(0,25)}`, date: d.slice(0,10), type: w.wo_type });
});
(pers.expiring_soon || []).forEach((p: any) => {
if (p.due) ev.push({ id: p.specialist+p.due, title: `ПК: ${(p.specialist||'').slice(0,20)}`, date: p.due.slice(0,10), type: 'qualification_due' });
});
(lls.items || []).filter((l: any) => l.remaining?.days > 0 && l.remaining.days < 90).forEach((l: any) => {
const dd = new Date(); dd.setDate(dd.getDate() + l.remaining.days);
ev.push({ id: l.id, title: `Ресурс: ${(l.component_name||'').slice(0,20)}`, date: dd.toISOString().slice(0,10), type: 'life_limit' });
});
setEvents(ev); setLoading(false);
});
}, []);
const y = cur.getFullYear(), m = cur.getMonth();
const sd = (new Date(y,m,1).getDay()+6)%7;
const dim = new Date(y,m+1,0).getDate();
const days = useMemo(() => {
const a: (number|null)[] = [];
for (let i=0;i<sd;i++) a.push(null);
for (let d=1;d<=dim;d++) a.push(d);
return a;
}, [sd, dim]);
const evFor = (d: number) => {
const ds = `${y}-${String(m+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
return events.filter(e => e.date === ds);
};
const td = new Date();
return (
<PageLayout title="📅 Календарь ТО" subtitle="Плановые работы, дедлайны AD, сроки ПК, ресурсы">
{loading && <div className="text-center py-4 text-gray-400"> Загрузка...</div>}
<div className="flex items-center justify-between mb-4">
<button onClick={() => setCur(new Date(y,m-1,1))} className="px-3 py-1.5 rounded bg-gray-100 hover:bg-gray-200 text-sm"></button>
<h2 className="text-lg font-bold">{MO[m]} {y}</h2>
<button onClick={() => setCur(new Date(y,m+1,1))} className="px-3 py-1.5 rounded bg-gray-100 hover:bg-gray-200 text-sm"></button>
</div>
<div className="flex flex-wrap gap-3 mb-4 text-[10px]">
{[['scheduled','Плановое ТО'],['ad_compliance','ДЛГ'],['sb_compliance','SB'],['defect_rectification','Дефект'],['qualification_due','ПК'],['life_limit','Ресурс']].map(([k,l]) => (
<div key={k} className="flex items-center gap-1"><div className={`w-2.5 h-2.5 rounded-full ${TC[k]}`}/><span className="text-gray-500">{l}</span></div>
))}
</div>
<div className="grid grid-cols-7 gap-px bg-gray-200 rounded-lg overflow-hidden">
{DW.map(d => <div key={d} className="bg-gray-50 px-2 py-1.5 text-center text-xs font-medium text-gray-500">{d}</div>)}
{days.map((day, i) => {
if (!day) return <div key={`e${i}`} className="bg-white min-h-[80px]"/>;
const de = evFor(day);
const isT = day===td.getDate() && m===td.getMonth() && y===td.getFullYear();
return (
<div key={day} className={`bg-white min-h-[80px] p-1 ${isT ? 'ring-2 ring-blue-500 ring-inset' : ''}`}>
<div className={`text-xs font-medium mb-0.5 ${isT ? 'text-blue-600' : 'text-gray-600'}`}>{day}</div>
<div className="space-y-0.5">
{de.slice(0,3).map((e,j) => <div key={j} className={`text-[9px] text-white px-1 py-0.5 rounded truncate ${TC[e.type]||'bg-gray-400'}`} title={e.title}>{e.title}</div>)}
{de.length > 3 && <div className="text-[9px] text-gray-400 px-1">+{de.length-3}</div>}
</div>
</div>
);
})}
</div>
</PageLayout>
);
}

24
app/callback/page.tsx Normal file
View File

@ -0,0 +1,24 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function OIDCCallbackPage() {
const router = useRouter();
useEffect(() => {
// The useOIDCAuth hook in providers.tsx handles the code exchange.
// This page just shows a loading state while that happens.
const timeout = setTimeout(() => router.push('/dashboard'), 3000);
return () => clearTimeout(timeout);
}, [router]);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="text-center">
<div className="text-4xl mb-4">🔐</div>
<h2 className="text-lg font-bold text-primary-500 mb-2">Авторизация...</h2>
<p className="text-sm text-gray-500">Выполняется вход в систему</p>
</div>
</div>
);
}

267
app/fgis-revs/page.tsx Normal file
View File

@ -0,0 +1,267 @@
/**
* Интеграция ФГИС РЭВС Федеральная ГИС реестра эксплуатантов ВС.
* ВК РФ ст. 33, 36, 37.2; Приказ Росавиации 180-П; ФАП-148.
*/
'use client';
import { useState, useCallback } from 'react';
import { PageLayout, DataTable, StatusBadge, EmptyState } from '@/components/ui';
type Tab = 'aircraft' | 'certificates' | 'operators' | 'directives' | 'maint-orgs' | 'sync' | 'connection';
export default function FGISPage() {
const [tab, setTab] = useState<Tab>('aircraft');
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [syncResults, setSyncResults] = useState<any>(null);
const api = useCallback(async (ep: string, method = 'GET') => {
setLoading(true);
try {
const opts: RequestInit = { method };
const r = await fetch(`/api/v1/fgis-revs/${ep}`, opts);
const d = await r.json();
setData(d);
return d;
} finally { setLoading(false); }
}, []);
const loadTab = useCallback((t: Tab) => {
setTab(t);
setData(null);
switch (t) {
case 'aircraft': api('aircraft-registry'); break;
case 'certificates': api('certificates'); break;
case 'operators': api('operators'); break;
case 'directives': api('directives'); break;
case 'maint-orgs': api('maintenance-organizations'); break;
case 'sync': api('sync/status'); break;
case 'connection': api('connection-status'); break;
}
}, [api]);
const runSync = async (type: string) => {
setSyncing(true);
try {
const r = await fetch(`/api/v1/fgis-revs/sync/${type}`, { method: 'POST' });
const d = await r.json();
setSyncResults(d);
if (type === 'all') loadTab('sync');
} finally { setSyncing(false); }
};
const tabs: { id: Tab; label: string; icon: string }[] = [
{ id: 'aircraft', label: 'Реестр ВС', icon: '✈️' },
{ id: 'certificates', label: 'СЛГ', icon: '📜' },
{ id: 'operators', label: 'Эксплуатанты', icon: '🏢' },
{ id: 'directives', label: 'Директивы ЛГ', icon: '⚠️' },
{ id: 'maint-orgs', label: 'Орг. по ТО', icon: '🔧' },
{ id: 'sync', label: 'Синхронизация', icon: '🔄' },
{ id: 'connection', label: 'Подключение', icon: '🔌' },
];
return (
<PageLayout title="🏛️ ФГИС РЭВС" subtitle="Федеральная ГИС реестра эксплуатантов ВСВК РФ ст. 33; Приказ Росавиации № 180-П">
{/* Tabs */}
<div className="flex flex-wrap gap-1 mb-4 border-b pb-2">
{tabs.map(t => (
<button key={t.id} onClick={() => loadTab(t.id)}
className={`px-3 py-1.5 rounded-t text-xs transition-colors ${tab === t.id
? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
{t.icon} {t.label}
</button>
))}
</div>
{loading && <div className="text-center py-8 text-gray-400"> Загрузка данных из ФГИС РЭВС...</div>}
{/* Aircraft Registry */}
{tab === 'aircraft' && data && !loading && (
<div>
<div className="text-xs text-gray-400 mb-2">📡 Источник: {data.source} | {data.legal_basis} | {data.total} записей</div>
<DataTable columns={[
{ key: 'registration', label: 'Рег. знак' },
{ key: 'aircraft_type', label: 'Тип ВС' },
{ key: 'serial_number', label: 'Серийный №' },
{ key: 'manufacturer', label: 'Изготовитель', render: (v: string) => <span className="text-xs">{(v||'').slice(0,30)}</span> },
{ key: 'year_manufactured', label: 'Год' },
{ key: 'operator', label: 'Эксплуатант' },
{ key: 'base_airport', label: 'Аэродром' },
{ key: 'status', label: 'Статус', render: (v: string) => (
<StatusBadge status={v} colorMap={{ active: 'bg-green-500', stored: 'bg-yellow-500', deregistered: 'bg-red-500' }}
labelMap={{ active: 'Действующий', stored: 'На хранении', deregistered: 'Снят с учёта' }} />
)},
]} data={data.items || []} />
</div>
)}
{/* Certificates */}
{tab === 'certificates' && data && !loading && (
<div>
<div className="text-xs text-gray-400 mb-2">📡 {data.source} | {data.legal_basis}</div>
<DataTable columns={[
{ key: 'certificate_number', label: '№ СЛГ' },
{ key: 'aircraft_registration', label: 'Борт' },
{ key: 'certificate_type', label: 'Тип', render: (v: string) => ({ standard: 'Стандартный', restricted: 'Ограниченный', special: 'Специальный', export: 'Экспортный' }[v] || v) },
{ key: 'category', label: 'Категория' },
{ key: 'issue_date', label: 'Выдан' },
{ key: 'expiry_date', label: 'Действует до' },
{ key: 'status', label: 'Статус', render: (v: string) => (
<StatusBadge status={v} colorMap={{ valid: 'bg-green-500', expired: 'bg-red-500', suspended: 'bg-yellow-500', revoked: 'bg-gray-500' }}
labelMap={{ valid: 'Действует', expired: 'Истёк', suspended: 'Приостановлен', revoked: 'Аннулирован' }} />
)},
]} data={data.items || []} />
</div>
)}
{/* Operators */}
{tab === 'operators' && data && !loading && (
<div>
<div className="text-xs text-gray-400 mb-2">📡 {data.source} | {data.legal_basis}</div>
{(data.items || []).map((op: any, i: number) => (
<div key={i} className="card p-4 mb-3">
<div className="flex justify-between items-start">
<div>
<h3 className="font-bold text-sm">{op.name}</h3>
<div className="text-xs text-gray-500 mt-1">СЭ: {op.certificate_number} | ИНН: {op.inn} | ОГРН: {op.ogrn}</div>
</div>
<StatusBadge status={op.status} colorMap={{ active: 'bg-green-500', suspended: 'bg-yellow-500', revoked: 'bg-red-500' }}
labelMap={{ active: 'Действующий', suspended: 'Приостановлен', revoked: 'Аннулирован' }} />
</div>
<div className="mt-2 flex flex-wrap gap-1">
{(op.aircraft_types || []).map((t: string) => (
<span key={t} className="text-[10px] bg-blue-50 text-blue-600 px-2 py-0.5 rounded">{t}</span>
))}
</div>
<div className="text-[10px] text-gray-400 mt-1">Парк: {op.fleet_count} ВС | Действует: {op.issue_date} {op.expiry_date}</div>
</div>
))}
</div>
)}
{/* Directives */}
{tab === 'directives' && data && !loading && (
<div>
<div className="text-xs text-gray-400 mb-2">📡 {data.source} | {data.legal_basis}</div>
<DataTable columns={[
{ key: 'number', label: '№ ДЛГ' },
{ key: 'title', label: 'Наименование' },
{ key: 'aircraft_types', label: 'Типы ВС', render: (v: any) => (v || []).join(', ') },
{ key: 'ata_chapter', label: 'ATA' },
{ key: 'effective_date', label: 'С даты' },
{ key: 'compliance_type', label: 'Тип', render: (v: string) => (
<StatusBadge status={v} colorMap={{ mandatory: 'bg-red-500', recommended: 'bg-blue-400', info: 'bg-gray-400' }} />
)},
]} data={data.items || []} />
<button onClick={() => runSync('directives')} disabled={syncing}
className="mt-3 btn-primary px-4 py-2 rounded text-xs disabled:opacity-50">
{syncing ? '⏳ Импорт...' : '📥 Импортировать ДЛГ в систему'}
</button>
</div>
)}
{/* Maintenance Organizations */}
{tab === 'maint-orgs' && data && !loading && (
<div>
<div className="text-xs text-gray-400 mb-2">📡 {data.source} | {data.legal_basis}</div>
{(data.items || []).map((org: any, i: number) => (
<div key={i} className="card p-4 mb-3">
<div className="flex justify-between">
<div>
<h3 className="font-bold text-sm">{org.name}</h3>
<div className="text-xs text-gray-500">Сертификат: {org.certificate_number}</div>
</div>
<StatusBadge status={org.status} colorMap={{ active: 'bg-green-500' }} />
</div>
<div className="mt-2 flex flex-wrap gap-1">
{(org.approval_scope || []).map((s: string) => (
<span key={s} className="text-[10px] bg-green-50 text-green-600 px-2 py-0.5 rounded font-mono">{s}</span>
))}
</div>
<div className="text-[10px] text-gray-400 mt-1">Действует: {org.issue_date} {org.expiry_date}</div>
</div>
))}
</div>
)}
{/* Sync */}
{tab === 'sync' && !loading && (
<div className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[
{ type: 'aircraft', label: '✈️ Реестр ВС', desc: 'Двунаправленная синх.' },
{ type: 'certificates', label: '📜 СЛГ', desc: 'Pull из ФГИС' },
{ type: 'directives', label: '⚠️ Директивы ЛГ', desc: 'Pull + auto-create AD' },
{ type: 'all', label: '🔄 Полная синхр.', desc: 'Все реестры' },
].map(s => (
<button key={s.type} onClick={() => runSync(s.type)} disabled={syncing}
className="card p-4 text-left hover:shadow-md transition-shadow disabled:opacity-50">
<div className="text-sm font-medium">{s.label}</div>
<div className="text-[10px] text-gray-400 mt-1">{s.desc}</div>
</button>
))}
</div>
{syncing && <div className="text-center py-4 text-blue-500">🔄 Синхронизация...</div>}
{syncResults && (
<div className="card p-4 bg-green-50">
<h3 className="text-sm font-bold text-green-700 mb-2"> Результат синхронизации</h3>
<pre className="text-[10px] text-gray-600 overflow-x-auto">{JSON.stringify(syncResults, null, 2)}</pre>
</div>
)}
{data?.history?.length > 0 && (
<div>
<h3 className="text-sm font-bold text-gray-600 mb-2">📋 История синхронизаций</h3>
<DataTable columns={[
{ key: 'entity_type', label: 'Реестр' },
{ key: 'direction', label: 'Направление' },
{ key: 'status', label: 'Статус', render: (v: string) => (
<StatusBadge status={v} colorMap={{ success: 'bg-green-500', partial: 'bg-yellow-500', failed: 'bg-red-500' }} />
)},
{ key: 'records_synced', label: 'Синхр.' },
{ key: 'records_failed', label: 'Ошибки' },
{ key: 'started_at', label: 'Время', render: (v: string) => v ? new Date(v).toLocaleString('ru-RU') : '—' },
]} data={data.history} />
</div>
)}
</div>
)}
{/* Connection Status */}
{tab === 'connection' && data && !loading && (
<div className="space-y-4">
<div className="card p-4">
<h3 className="text-sm font-bold mb-2">📡 ФГИС РЭВС (REST API)</h3>
<div className="space-y-1 text-xs">
<div className="flex justify-between"><span className="text-gray-500">URL:</span><span className="font-mono">{data.fgis_revs?.url}</span></div>
<div className="flex justify-between"><span className="text-gray-500">Статус:</span>
<StatusBadge status={data.fgis_revs?.status} colorMap={{ connected: 'bg-green-500', mock_mode: 'bg-yellow-500', error: 'bg-red-500' }}
labelMap={{ mock_mode: 'Тестовый режим', connected: 'Подключено', error: 'Ошибка' }} />
</div>
<div className="text-[10px] text-gray-400 mt-1">{data.fgis_revs?.note}</div>
</div>
</div>
<div className="card p-4">
<h3 className="text-sm font-bold mb-2">🔐 СМЭВ 3.0 (SOAP)</h3>
<div className="space-y-1 text-xs">
<div className="flex justify-between"><span className="text-gray-500">URL:</span><span className="font-mono text-[10px]">{data.smev_30?.url}</span></div>
<div className="flex justify-between"><span className="text-gray-500">Статус:</span>
<StatusBadge status={data.smev_30?.status} colorMap={{ mock_mode: 'bg-yellow-500' }} labelMap={{ mock_mode: 'Тестовый режим' }} />
</div>
<div className="text-[10px] text-gray-400 mt-1">{data.smev_30?.note}</div>
</div>
</div>
<div className="card p-4">
<h3 className="text-sm font-bold mb-2"> Конфигурация</h3>
<div className="space-y-1 text-xs">
{Object.entries(data.config || {}).map(([k, v]) => (
<div key={k} className="flex justify-between"><span className="text-gray-500">{k}:</span><span className="font-mono">{String(v)}</span></div>
))}
</div>
</div>
</div>
)}
{!data && !loading && tab !== 'sync' && <EmptyState message="Выберите раздел для загрузки данных" />}
</PageLayout>
);
}

69
app/help/page.tsx Normal file
View File

@ -0,0 +1,69 @@
'use client';
import { useState } from 'react';
import { PageLayout } from '@/components/ui';
const DOCS = [
{ cat: 'Законодательство РФ', items: [
{ name: 'Воздушный кодекс РФ', ref: '60-ФЗ от 19.03.1997', articles: 'ст. 8, 24.1, 28, 33, 35, 36, 37, 37.2, 52-54' },
{ name: 'ФЗ-488', ref: '30.12.2021', articles: 'ст. 37.2 — поддержание ЛГ' },
{ name: 'ФЗ-152', ref: 'О персональных данных', articles: 'Защита ПДн в панели ФАВТ' },
]},
{ cat: 'ФАП (Федеральные авиационные правила)', items: [
{ name: 'ФАП-10', ref: 'Сертификация эксплуатантов', articles: 'Общие требования' },
{ name: 'ФАП-21', ref: 'Сертификация АТ', articles: 'Part-21 эквивалент' },
{ name: 'ФАП-128', ref: 'Подготовка и выполнение полётов', articles: 'Эксплуатация ВС' },
{ name: 'ФАП-145', ref: 'Организации по ТО', articles: 'п.A.30, A.35, A.42, A.50-65' },
{ name: 'ФАП-147', ref: 'Учебные организации', articles: 'п.17 — программы подготовки' },
{ name: 'ФАП-148', ref: 'Поддержание ЛГ', articles: 'п.3, 4.2, 4.3, 4.5' },
{ name: 'ФАП-149', ref: 'Инспектирование ГА', articles: 'Надзорные функции' },
{ name: 'ФАП-246', ref: 'Сертификация эксплуатантов', articles: 'Процедуры сертификации' },
]},
{ cat: 'ICAO', items: [
{ name: 'Annex 1', ref: 'Licensing', articles: 'Лицензирование персонала' },
{ name: 'Annex 6', ref: 'Operation', articles: 'Part I 8.3, 8.7 — ТО' },
{ name: 'Annex 8', ref: 'Airworthiness', articles: 'Part II 4.2 — ресурсы' },
{ name: 'Annex 19', ref: 'Safety Management', articles: 'SMS' },
{ name: 'Doc 9734', ref: 'Safety Oversight', articles: 'CE-7' },
{ name: 'Doc 9760', ref: 'Airworthiness Manual', articles: 'ch.6 — персонал' },
{ name: 'Doc 9859', ref: 'SMM', articles: 'ch.2 — human factors' },
]},
{ cat: 'EASA', items: [
{ name: 'Part-21', ref: 'Certification', articles: 'A.3B, A.97' },
{ name: 'Part-66', ref: 'Licensing', articles: 'A.25, A.30, A.40, A.45' },
{ name: 'Part-M', ref: 'Continuing Airworthiness', articles: 'A.301, A.302, A.403, A.501, A.901' },
{ name: 'Part-145', ref: 'Maintenance Organisations', articles: 'A.30, A.35, A.42, A.50-65' },
{ name: 'Part-CAMO', ref: 'Continuing Airworthiness Mgmt', articles: 'A.305' },
]},
];
export default function HelpPage() {
const [search, setSearch] = useState('');
const filtered = DOCS.map(cat => ({
...cat,
items: cat.items.filter(i => !search || [i.name, i.ref, i.articles].some(s => s.toLowerCase().includes(search.toLowerCase())))
})).filter(cat => cat.items.length > 0);
return (
<PageLayout title="📚 Справка" subtitle="Нормативная база АСУ ТК — 19 документов">
<input type="text" placeholder="🔍 Поиск по нормативной базе..." value={search}
onChange={e => setSearch(e.target.value)}
className="w-full max-w-md px-3 py-2 rounded-lg bg-gray-100 text-sm mb-6 focus:ring-2 focus:ring-blue-500" />
<div className="space-y-6">
{filtered.map(cat => (
<section key={cat.cat}>
<h3 className="text-sm font-bold text-gray-600 mb-2">{cat.cat}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{cat.items.map(item => (
<div key={item.name} className="card p-3">
<div className="font-medium text-sm">{item.name}</div>
<div className="text-xs text-gray-500">{item.ref}</div>
<div className="text-[10px] text-blue-600 mt-1">{item.articles}</div>
</div>
))}
</div>
</section>
))}
</div>
</PageLayout>
);
}

46
app/login/page.tsx Normal file
View File

@ -0,0 +1,46 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
import Logo from '@/components/Logo';
export default function LoginPage() {
const { login, isAuthenticated, loading } = useAuth();
const router = useRouter();
const [token, setToken] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
if (!loading && isAuthenticated) { router.push('/dashboard'); return null; }
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); setError(''); setSubmitting(true);
try { await login(token || 'dev'); router.push('/dashboard'); }
catch { setError('Неверный токен или сервер недоступен'); }
finally { setSubmitting(false); }
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-12 rounded-xl shadow-lg max-w-md w-full">
<div className="text-center mb-8">
<Logo size="large" />
<h1 className="text-2xl font-bold text-primary-500 mt-4">КЛГ АСУ ТК</h1>
<p className="text-sm text-gray-400 mt-2">Контроль лётной годности · Вход</p>
</div>
<form onSubmit={handleLogin}>
<label className="block text-sm font-bold text-gray-600 mb-1">Токен доступа</label>
<input type="password" value={token} onChange={e => setToken(e.target.value)}
placeholder="Введите токен или оставьте пустым для dev"
className="input-field mb-4" />
{error && <div className="bg-red-50 p-3 rounded text-red-700 text-sm mb-4">{error}</div>}
<button type="submit" disabled={submitting}
className="w-full py-3 bg-primary-500 text-white rounded-md text-lg font-bold hover:bg-primary-600 transition-colors disabled:opacity-60 border-none cursor-pointer">
{submitting ? 'Вход...' : 'Войти'}
</button>
</form>
<div className="text-center mt-6 text-xs text-gray-300">АО «REFLY» · {new Date().getFullYear()}</div>
</div>
</div>
);
}

View File

@ -0,0 +1,6 @@
'use client';
import NotificationCenter from '@/components/NotificationCenter';
export default function NotificationsPage() {
return <NotificationCenter />;
}

14
app/offline/page.tsx Normal file
View File

@ -0,0 +1,14 @@
'use client';
export default function OfflinePage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="text-center max-w-md p-8">
<div className="text-6xl mb-4"></div>
<h1 className="text-2xl font-bold text-primary-500 mb-2">Нет подключения</h1>
<p className="text-gray-500 mb-6">Проверьте интернет-соединение и попробуйте снова.</p>
<button onClick={() => window.location.reload()} className="btn-primary">Обновить</button>
</div>
</div>
);
}

325
app/personnel-plg/page.tsx Normal file
View File

@ -0,0 +1,325 @@
/**
* Сертификация персонала ПЛГ
* Учёт специалистов, первичная аттестация, повышение квалификации.
*
* Правовые основания:
* ВК РФ ст. 52-54; ФАП-147; ФАП-145; ФАП-148
* EASA Part-66, Part-145.A.30/35, Part-CAMO.A.305
* ICAO Annex 1, Doc 9760 ch.6
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { PageLayout, DataTable, StatusBadge, Modal, EmptyState } from '@/components/ui';
type Tab = 'specialists' | 'programs' | 'attestations' | 'compliance';
interface Specialist { id: string; full_name: string; personnel_number: string; position: string; category: string; specializations: string[]; license_number?: string; license_expires?: string; status: string; compliance?: any; attestations?: any[]; qualifications?: any[]; }
interface Program { id: string; name: string; type: string; legal_basis: string; duration_hours: number; modules?: any[]; periodicity?: string; certificate_validity_years?: number; }
export default function PersonnelPLGPage() {
const [tab, setTab] = useState<Tab>('specialists');
const [specialists, setSpecialists] = useState<Specialist[]>([]);
const [programs, setPrograms] = useState<Program[]>([]);
const [compliance, setCompliance] = useState<any>(null);
const [selected, setSelected] = useState<Specialist | null>(null);
const [selectedProgram, setSelectedProgram] = useState<Program | null>(null);
const [showAddModal, setShowAddModal] = useState(false);
const [loading, setLoading] = useState(false);
const api = useCallback(async (endpoint: string, opts?: RequestInit) => {
const res = await fetch(`/api/v1/personnel-plg/${endpoint}`, opts);
return res.json();
}, []);
useEffect(() => {
setLoading(true);
Promise.all([
api('specialists').then(d => setSpecialists(d.items || [])),
api('programs').then(d => setPrograms(d.programs || [])),
api('compliance-report').then(d => setCompliance(d)),
]).finally(() => setLoading(false));
}, [api]);
const handleAddSpecialist = async (data: any) => {
const result = await api('specialists', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
});
if (result.id) { setSpecialists(prev => [...prev, result]); setShowAddModal(false); }
};
const tabs = [
{ id: 'specialists' as Tab, label: '👤 Специалисты', icon: '👤' },
{ id: 'programs' as Tab, label: '📚 Программы подготовки', icon: '📚' },
{ id: 'attestations' as Tab, label: '📝 Аттестация / ПК', icon: '📝' },
{ id: 'compliance' as Tab, label: '✅ Соответствие', icon: '✅' },
];
const programTypeLabels: Record<string, string> = {
initial: '🎓 Первичная', recurrent: '🔄 Периодическая', type_rating: '✈️ На тип ВС',
ewis: '⚡ EWIS', fuel_tank: '⛽ FTS', ndt: '🔬 НК/NDT', human_factors: '🧠 ЧФ',
sms: '🛡️ SMS', crs_authorization: '✍️ CRS', rvsm: '📏 RVSM', etops: '🌊 ETOPS',
};
return (
<PageLayout title="🎓 Сертификация персонала ПЛГ"
subtitle="Учёт специалистов, аттестация, повышение квалификации"
actions={
<button onClick={() => setShowAddModal(true)} className="btn-primary text-sm px-4 py-2 rounded">
+ Добавить специалиста
</button>
}>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4 text-xs text-blue-700">
<strong>Правовая база:</strong> ВК РФ ст. 52-54; ФАП-147; ФАП-145 п.145.A.30/35; ФАП-148;
EASA Part-66; Part-CAMO.A.305; ICAO Annex 1; Doc 9760 ch.6
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-gray-200 overflow-x-auto">
{tabs.map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
${tab === t.id ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
{t.label}
</button>
))}
</div>
{loading ? <div className="text-center py-10 text-gray-400"> Загрузка...</div> : (
<>
{/* SPECIALISTS */}
{tab === 'specialists' && (
specialists.length > 0 ? (
<DataTable
columns={[
{ key: 'personnel_number', label: 'Таб. №' },
{ key: 'full_name', label: 'ФИО' },
{ key: 'position', label: 'Должность' },
{ key: 'category', label: 'Категория', render: (v: string) => <span className="badge bg-blue-100 text-blue-700">{v}</span> },
{ key: 'specializations', label: 'Типы ВС', render: (v: string[]) => v?.join(', ') || '—' },
{ key: 'license_number', label: 'Свидетельство' },
{ key: 'status', label: 'Статус', render: (v: string) => (
<StatusBadge status={v} colorMap={{ active: 'bg-green-500', suspended: 'bg-red-500', expired: 'bg-yellow-500' }}
labelMap={{ active: 'Действует', suspended: 'Приостановлен', expired: 'Истёк' }} />
)},
]}
data={specialists}
onRowClick={(row) => {
api(`specialists/${row.id}`).then(setSelected);
}}
/>
) : <EmptyState message="Нет специалистов. Добавьте первого специалиста ПЛГ." />
)}
{/* PROGRAMS */}
{tab === 'programs' && (
<div className="space-y-3">
{programs.map(p => (
<div key={p.id} onClick={() => setSelectedProgram(p)}
className="card p-4 cursor-pointer hover:shadow-md transition-shadow">
<div className="flex justify-between items-start">
<div>
<div className="flex items-center gap-2">
<span className="text-lg">{programTypeLabels[p.type]?.split(' ')[0] || '📋'}</span>
<span className="font-medium text-sm">{p.name}</span>
</div>
<div className="text-xs text-gray-500 mt-1">{p.legal_basis}</div>
</div>
<div className="text-right shrink-0">
<div className="badge bg-primary-100 text-primary-700">{p.duration_hours} ч.</div>
{p.periodicity && <div className="text-[10px] text-gray-400 mt-1">{p.periodicity}</div>}
{p.certificate_validity_years ? (
<div className="text-[10px] text-gray-400">Срок: {p.certificate_validity_years} лет</div>
) : null}
</div>
</div>
</div>
))}
</div>
)}
{/* ATTESTATIONS */}
{tab === 'attestations' && (
<div className="space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="card p-4 text-center border-l-4 border-green-500">
<div className="text-2xl font-bold text-green-600">🎓</div>
<div className="text-sm font-medium mt-1">Первичная аттестация</div>
<div className="text-xs text-gray-400">PLG-INIT-001 · 240 ч.</div>
</div>
<div className="card p-4 text-center border-l-4 border-blue-500">
<div className="text-2xl font-bold text-blue-600">🔄</div>
<div className="text-sm font-medium mt-1">Периодическая ПК</div>
<div className="text-xs text-gray-400">PLG-REC-001 · 40 ч. · каждые 24 мес.</div>
</div>
<div className="card p-4 text-center border-l-4 border-purple-500">
<div className="text-2xl font-bold text-purple-600"></div>
<div className="text-sm font-medium mt-1">Допуск на тип ВС</div>
<div className="text-xs text-gray-400">PLG-TYPE-001 · 80 ч.</div>
</div>
</div>
<h3 className="text-sm font-bold text-gray-600 mt-6">Специальные курсы</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{['PLG-EWIS-001', 'PLG-FUEL-001', 'PLG-NDT-001', 'PLG-HF-001', 'PLG-SMS-001', 'PLG-CRS-001', 'PLG-RVSM-001', 'PLG-ETOPS-001'].map(pid => {
const p = programs.find(pp => pp.id === pid);
if (!p) return null;
return (
<div key={pid} onClick={() => setSelectedProgram(p)}
className="card p-3 cursor-pointer hover:shadow-sm transition-shadow">
<div className="text-sm font-medium">{programTypeLabels[p.type] || p.type}</div>
<div className="text-[10px] text-gray-400 mt-1">{p.duration_hours} ч.</div>
</div>
);
})}
</div>
</div>
)}
{/* COMPLIANCE */}
{tab === 'compliance' && compliance && (
<div className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="card p-4 text-center"><div className="text-3xl font-bold">{compliance.total_specialists}</div><div className="text-xs text-gray-500">Всего специалистов</div></div>
<div className="card p-4 text-center bg-green-50"><div className="text-3xl font-bold text-green-600">{compliance.compliant}</div><div className="text-xs text-green-600">Соответствуют</div></div>
<div className="card p-4 text-center bg-red-50"><div className="text-3xl font-bold text-red-600">{compliance.non_compliant}</div><div className="text-xs text-red-600">Нарушения</div></div>
<div className="card p-4 text-center bg-yellow-50"><div className="text-3xl font-bold text-yellow-600">{compliance.expiring_soon?.length || 0}</div><div className="text-xs text-yellow-600">Истекает &lt;90 дн.</div></div>
</div>
{compliance.overdue?.length > 0 && (
<div className="card p-4 border-l-4 border-red-500">
<h4 className="text-sm font-bold text-red-700 mb-2"> Просроченные квалификации</h4>
{compliance.overdue.map((o: any, i: number) => (
<div key={i} className="flex justify-between py-1.5 border-b border-red-100 text-sm">
<span className="font-medium">{o.specialist}</span>
<span className="text-gray-500">{o.program}</span>
<span className="text-red-600 text-xs">{new Date(o.due).toLocaleDateString('ru-RU')}</span>
</div>
))}
</div>
)}
{compliance.expiring_soon?.length > 0 && (
<div className="card p-4 border-l-4 border-yellow-500">
<h4 className="text-sm font-bold text-yellow-700 mb-2"> Истекает в течение 90 дней</h4>
{compliance.expiring_soon.map((e: any, i: number) => (
<div key={i} className="flex justify-between py-1.5 border-b border-yellow-100 text-sm">
<span className="font-medium">{e.specialist}</span>
<span className="text-gray-500">{e.program || e.item}</span>
<span className="text-yellow-600 text-xs">{new Date(e.due).toLocaleDateString('ru-RU')}</span>
</div>
))}
</div>
)}
</div>
)}
</>
)}
{/* Specialist detail modal */}
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title={selected?.full_name || ''} size="lg">
{selected && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 text-sm">
<div><span className="text-gray-500">Таб. :</span> {selected.personnel_number}</div>
<div><span className="text-gray-500">Категория:</span> <span className="badge bg-blue-100 text-blue-700">{selected.category}</span></div>
<div><span className="text-gray-500">Должность:</span> {selected.position}</div>
<div><span className="text-gray-500">Свидетельство:</span> {selected.license_number || '—'}</div>
<div><span className="text-gray-500">Типы ВС:</span> {selected.specializations?.join(', ') || '—'}</div>
<div><span className="text-gray-500">Действует до:</span> {selected.license_expires ? new Date(selected.license_expires).toLocaleDateString('ru-RU') : '—'}</div>
</div>
{selected.compliance && (
<div className={`p-3 rounded text-sm ${selected.compliance.status === 'compliant' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
{selected.compliance.status === 'compliant' ? '✅ Все квалификации в порядке' : `⚠️ Просрочено: ${selected.compliance.overdue_items?.join(', ')}`}
</div>
)}
{selected.attestations?.length > 0 && (
<div><h4 className="text-sm font-bold mb-2">Аттестации</h4>
{selected.attestations.map((a: any) => (
<div key={a.id} className="flex justify-between py-1 border-b border-gray-50 text-xs">
<span>{a.program_name}</span><span className={a.result === 'passed' ? 'text-green-600' : 'text-red-600'}>{a.result}</span>
</div>
))}
</div>
)}
{selected.qualifications?.length > 0 && (
<div><h4 className="text-sm font-bold mb-2">Повышение квалификации</h4>
{selected.qualifications.map((q: any) => (
<div key={q.id} className="flex justify-between py-1 border-b border-gray-50 text-xs">
<span>{q.program_name}</span><span>{q.next_due ? `до ${new Date(q.next_due).toLocaleDateString('ru-RU')}` : '—'}</span>
</div>
))}
</div>
)}
</div>
)}
</Modal>
{/* Program detail modal */}
<Modal isOpen={!!selectedProgram} onClose={() => setSelectedProgram(null)} title={selectedProgram?.name || ''} size="lg">
{selectedProgram && (
<div className="space-y-3">
<div className="text-xs text-gray-500">{selectedProgram.legal_basis}</div>
<div className="flex gap-3 text-sm">
<span className="badge bg-primary-100 text-primary-700">{selectedProgram.duration_hours} часов</span>
{selectedProgram.periodicity && <span className="badge bg-yellow-100 text-yellow-700">{selectedProgram.periodicity}</span>}
{selectedProgram.certificate_validity_years ? <span className="badge bg-green-100 text-green-700">Срок: {selectedProgram.certificate_validity_years} лет</span> : null}
</div>
{selectedProgram.modules && (
<div>
<h4 className="text-sm font-bold mb-2">Модули программы</h4>
<div className="space-y-1">
{selectedProgram.modules.map((m: any, i: number) => (
<div key={i} className="flex justify-between py-1.5 border-b border-gray-50 text-xs">
<div><span className="font-mono text-gray-400 mr-2">{m.code}</span>{m.name}</div>
<div className="flex gap-2 shrink-0">
{m.hours > 0 && <span className="badge bg-gray-100">{m.hours}ч</span>}
{m.basis && <span className="text-[10px] text-gray-400 max-w-[200px] truncate">{m.basis}</span>}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</Modal>
{/* Add specialist modal */}
<Modal isOpen={showAddModal} onClose={() => setShowAddModal(false)} title="Добавить специалиста ПЛГ" size="lg">
<AddSpecialistForm onSubmit={handleAddSpecialist} onCancel={() => setShowAddModal(false)} />
</Modal>
</PageLayout>
);
}
function AddSpecialistForm({ onSubmit, onCancel }: { onSubmit: (d: any) => void; onCancel: () => void }) {
const [form, setForm] = useState({ full_name: '', personnel_number: '', position: 'Авиатехник', category: 'B1', specializations: '', license_number: '' });
return (
<div className="space-y-3">
{[
{ key: 'full_name', label: 'ФИО', placeholder: 'Иванов Иван Иванович' },
{ key: 'personnel_number', label: 'Табельный номер', placeholder: 'ТН-001' },
{ key: 'position', label: 'Должность', placeholder: 'Авиатехник' },
{ key: 'license_number', label: 'Номер свидетельства', placeholder: 'АС-12345' },
{ key: 'specializations', label: 'Типы ВС (через запятую)', placeholder: 'Ан-148, SSJ-100' },
].map(f => (
<div key={f.key}>
<label className="text-xs font-medium text-gray-600">{f.label}</label>
<input className="input-field w-full mt-1" placeholder={f.placeholder}
value={(form as any)[f.key]} onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))} />
</div>
))}
<div>
<label className="text-xs font-medium text-gray-600">Категория (EASA Part-66 / ФАП-147)</label>
<select className="input-field w-full mt-1" value={form.category} onChange={e => setForm(p => ({ ...p, category: e.target.value }))}>
{['A', 'B1', 'B2', 'B3', 'C', 'I', 'II', 'III'].map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div className="flex gap-2 pt-2">
<button onClick={() => onSubmit({ ...form, specializations: form.specializations.split(',').map(s => s.trim()).filter(Boolean) })}
className="btn-primary px-4 py-2 rounded text-sm">Сохранить</button>
<button onClick={onCancel} className="btn-sm bg-gray-200 text-gray-700 px-4 py-2 rounded text-sm">Отмена</button>
</div>
</div>
);
}

53
app/print/crs/page.tsx Normal file
View File

@ -0,0 +1,53 @@
'use client';
import { useState, useEffect, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
function CRSContent() {
const params = useSearchParams();
const woId = params.get('wo_id');
const [wo, setWo] = useState<any>(null);
useEffect(() => {
if (woId) fetch(`/api/v1/work-orders/${woId}`).then(r => r.json()).then(setWo);
}, [woId]);
useEffect(() => { if (wo?.crs_signed_by) setTimeout(() => window.print(), 500); }, [wo]);
if (!wo) return <div className="p-8 text-center text-gray-400">Загрузка...</div>;
return (
<div className="max-w-[800px] mx-auto p-8 font-serif text-sm">
<div className="text-center border-b-2 border-black pb-4 mb-4">
<h1 className="text-lg font-bold">CERTIFICATE OF RELEASE TO SERVICE</h1>
<h2 className="text-base">СВИДЕТЕЛЬСТВО О ДОПУСКЕ К ЭКСПЛУАТАЦИИ</h2>
<p className="text-xs text-gray-500 mt-1">ФАП-145 п.145.A.50 | EASA Part-145.A.50</p>
</div>
<table className="w-full border-collapse mb-4">
<tbody>
{[['Наряд / WO No:', wo.wo_number], ['Борт:', wo.aircraft_reg], ['Тип:', wo.wo_type],
['Работы:', wo.title], ['План. ч/ч:', wo.estimated_manhours], ['Факт. ч/ч:', wo.actual_manhours || '—']
].map(([k, v], i) => (
<tr key={i} className="border border-gray-300">
<td className="px-2 py-1 bg-gray-100 font-medium w-1/3">{k}</td>
<td className="px-2 py-1">{String(v)}</td>
</tr>
))}
</tbody>
</table>
{wo.description && <div className="mb-4"><h3 className="font-bold text-xs text-gray-600 mb-1">Описание работ:</h3><p className="border p-2">{wo.description}</p></div>}
{wo.findings && <div className="mb-4"><h3 className="font-bold text-xs text-gray-600 mb-1">Замечания:</h3><p className="border p-2">{wo.findings}</p></div>}
<div className="border-2 border-black p-4 mt-6">
<h3 className="font-bold text-center mb-3">RELEASE TO SERVICE / ДОПУСК К ЭКСПЛУАТАЦИИ</h3>
<p className="text-xs mb-4">Certifies that the work specified was carried out in accordance with ФАП-145 / Part-145 and the aircraft is ready for release to service.</p>
<div className="grid grid-cols-2 gap-4 mt-4">
<div><div className="text-xs text-gray-500">Подписал:</div><div className="border-b border-black mt-6 pt-1 font-bold">{wo.crs_signed_by || '________________'}</div></div>
<div><div className="text-xs text-gray-500">Дата:</div><div className="border-b border-black mt-6 pt-1">{wo.crs_date ? new Date(wo.crs_date).toLocaleDateString('ru-RU') : '________________'}</div></div>
</div>
</div>
<div className="mt-6 text-[10px] text-gray-400 text-center">АСУ ТК КЛГ | {new Date().toLocaleDateString('ru-RU')}</div>
</div>
);
}
export default function CRSPrintPage() {
return <Suspense fallback={<div className="p-8 text-center">Загрузка...</div>}><CRSContent /></Suspense>;
}

417
app/regulator/page.tsx Normal file
View File

@ -0,0 +1,417 @@
/**
* Панель регулятора ФАВТ
*
* Доступ: только роль favt_inspector (сотрудники ФАВТ) или admin.
* Показывает ТОЛЬКО агрегированные данные согласно:
* - ВК РФ ст. 8, 24.1, 28, 33, 36, 37, 67, 68
* - ФАП-246, ФАП-285, ФГИС РЭВС
* - ICAO Annex 6, 7, 8, 19; Doc 9734, 9760, 9859
* - EASA Part-M, Part-CAMO, Part-145, Part-ARO
*
* Разработчик: АО «REFLY»
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { PageLayout, DataTable, StatusBadge, EmptyState } from '@/components/ui';
import { useAuth } from '@/lib/auth-context';
import { useI18n } from '@/hooks/useI18n';
type Tab = 'overview' | 'aircraft' | 'certifications' | 'safety' | 'audits' | 'personnel';
interface OverviewData {
aircraft: { total: number; airworthy: number; in_maintenance: number; grounded: number; decommissioned: number };
organizations: { total: number };
certification: { total_applications: number; pending: number; approved: number; rejected: number };
safety: { total_risks: number; critical: number; high: number; unresolved: number };
audits_last_30d: number;
legal_basis: string[];
}
const TABS: { id: Tab; label: string; icon: string }[] = [
{ id: 'overview', label: 'Сводка', icon: '📊' },
{ id: 'aircraft', label: 'Реестр ВС', icon: '✈️' },
{ id: 'certifications', label: 'Сертификация', icon: '📋' },
{ id: 'safety', label: 'Безопасность', icon: '🛡️' },
{ id: 'audits', label: 'Аудиты', icon: '🔍' },
{ id: 'personnel' as Tab, label: 'Персонал ПЛГ', icon: '🎓' },
];
function AccessDenied() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center max-w-md">
<div className="text-6xl mb-4">🔒</div>
<h1 className="text-2xl font-bold text-gray-800 mb-2">Доступ ограничен</h1>
<p className="text-gray-500 mb-4">
Панель регулятора доступна только уполномоченным сотрудникам ФАВТ (Росавиации).
</p>
<p className="text-xs text-gray-400">
Основание: ВК РФ ст. 8 Федеральные правила использования воздушного пространства.
Для получения доступа обратитесь к администратору системы.
</p>
</div>
</div>
);
}
function StatCard({ label, value, sub, color = 'primary' }: { label: string; value: number; sub?: string; color?: string }) {
const colors: Record<string, string> = {
primary: 'bg-blue-50 text-blue-700 border-blue-200',
green: 'bg-green-50 text-green-700 border-green-200',
yellow: 'bg-yellow-50 text-yellow-700 border-yellow-200',
red: 'bg-red-50 text-red-700 border-red-200',
gray: 'bg-gray-50 text-gray-700 border-gray-200',
};
return (
<div className={`rounded-lg border p-4 ${colors[color] || colors.primary}`}>
<div className="text-3xl font-bold">{value}</div>
<div className="text-sm font-medium mt-1">{label}</div>
{sub && <div className="text-[10px] opacity-60 mt-0.5">{sub}</div>}
</div>
);
}
function LegalBasisBadge({ items }: { items: string[] }) {
return (
<details className="text-xs text-gray-400 mt-3">
<summary className="cursor-pointer hover:text-gray-600">📜 Правовые основания ({items.length})</summary>
<ul className="mt-1 space-y-0.5 pl-4">
{items.map((b, i) => <li key={i} className="list-disc">{b}</li>)}
</ul>
</details>
);
}
export default function RegulatorPanel() {
const { user } = useAuth();
const [tab, setTab] = useState<Tab>('overview');
const [overview, setOverview] = useState<OverviewData | null>(null);
const [aircraftData, setAircraftData] = useState<any>(null);
const [certData, setCertData] = useState<any>(null);
const [safetyData, setSafetyData] = useState<any>(null);
const [auditData, setAuditData] = useState<any>(null);
const [personnelData, setPersonnelData] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [days, setDays] = useState(90);
// Access control: only favt_inspector or admin
const hasAccess = user?.role === 'favt_inspector' || user?.role === 'admin'
|| user?.roles?.includes('favt_inspector') || user?.roles?.includes('admin');
const fetchData = useCallback(async (endpoint: string) => {
try {
const res = await fetch(`/api/v1/regulator/${endpoint}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (e) {
console.error(`Regulator API error:`, e);
return null;
}
}, []);
useEffect(() => {
if (!hasAccess) return;
setLoading(true);
fetchData('overview').then(d => { setOverview(d); setLoading(false); });
}, [hasAccess, fetchData]);
useEffect(() => {
if (!hasAccess) return;
if (tab === 'aircraft' && !aircraftData) fetchData('aircraft-register').then(setAircraftData);
if (tab === 'certifications' && !certData) fetchData('certifications').then(setCertData);
if (tab === 'safety' && !safetyData) fetchData(`safety-indicators?days=${days}`).then(setSafetyData);
if (tab === 'audits' && !auditData) fetchData(`audits?days=${days}`).then(setAuditData);
if (tab === 'personnel' && !personnelData) fetchData('personnel-summary').then(setPersonnelData);
}, [tab, hasAccess, days, fetchData, aircraftData, certData, safetyData, auditData]);
if (!hasAccess) return <AccessDenied />;
const handlePdfExport = async () => {
try {
const res = await fetch('/api/v1/regulator/report/pdf');
if (!res.ok) throw new Error('PDF generation failed');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `favt_report_${new Date().toISOString().slice(0, 10)}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch (e) { console.error(e); }
};
const handleExport = async () => {
const data = await fetchData('report');
if (data) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `favt_report_${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
}
};
return (
<PageLayout
title="🏛️ Панель регулятора — ФАВТ"
subtitle="Федеральное агентство воздушного транспорта (Росавиация)"
actions={
<div className="flex gap-2">
<button onClick={handleExport} className="btn-sm bg-blue-600 text-white px-4 py-2 rounded flex items-center gap-1">
📄 JSON отчёт
</button>
<button onClick={handlePdfExport} className="btn-sm bg-red-600 text-white px-4 py-2 rounded flex items-center gap-1">
📑 PDF отчёт
</button>
</div>
}
>
{/* Disclaimer */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4 text-xs text-blue-700">
<strong> Ограниченный доступ.</strong> Данные предоставляются в агрегированном виде согласно
ВК РФ (60-ФЗ), ФАП-21/128/145/147/148/149/246, ФЗ-488, ICAO Annex 6/8/19, EASA Part-M/CAMO/145/ARO, Поручение Президента Пр-1379, ТЗ АСУ ТК.
Персональные данные и коммерческая тайна не раскрываются.
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-gray-200 overflow-x-auto">
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
${tab === t.id ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
{t.icon} {t.label}
</button>
))}
</div>
{loading && !overview ? (
<div className="text-center py-12 text-gray-400"> Загрузка данных...</div>
) : (
<>
{/* === OVERVIEW TAB === */}
{tab === 'overview' && overview && (
<div className="space-y-6">
{/* Aircraft section — ВК РФ ст. 33, ICAO Annex 7 */}
<section>
<h3 className="text-sm font-bold text-gray-600 mb-3"> Состояние парка ВС (ВК РФ ст. 33; ICAO Annex 7, 8)</h3>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
<StatCard label="Всего ВС" value={overview.aircraft.total} sub="Государственный реестр" />
<StatCard label="Годные к полётам" value={overview.aircraft.airworthy} color="green" sub="Действующий СЛГ" />
<StatCard label="На ТО" value={overview.aircraft.in_maintenance} color="yellow" sub="Плановое / внеплановое" />
<StatCard label="Приостановлены" value={overview.aircraft.grounded} color="red" sub="Ограничение / запрет" />
<StatCard label="Списаны" value={overview.aircraft.decommissioned} color="gray" sub="Исключены из реестра" />
</div>
</section>
{/* Certification — ФАП-246, ICAO Annex 6 */}
<section>
<h3 className="text-sm font-bold text-gray-600 mb-3">📋 Сертификация эксплуатантов (ФАП-246; ICAO Annex 6)</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label="Всего заявок" value={overview.certification.total_applications} />
<StatCard label="На рассмотрении" value={overview.certification.pending} color="yellow" sub="Ожидают решения" />
<StatCard label="Одобрено" value={overview.certification.approved} color="green" />
<StatCard label="Отклонено" value={overview.certification.rejected} color="red" />
</div>
</section>
{/* Safety — ВК РФ ст. 24.1, ICAO Annex 19 */}
<section>
<h3 className="text-sm font-bold text-gray-600 mb-3">🛡 Показатели безопасности (ГПБП; ICAO Annex 19; Doc 9859)</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label="Всего рисков" value={overview.safety.total_risks} />
<StatCard label="Критические" value={overview.safety.critical} color="red" sub="Требуют немедленных мер" />
<StatCard label="Высокие" value={overview.safety.high} color="yellow" />
<StatCard label="Не устранены" value={overview.safety.unresolved} color="red" sub="Открытые риски" />
</div>
</section>
{/* Audits + Orgs */}
<section>
<h3 className="text-sm font-bold text-gray-600 mb-3">🔍 Надзорная деятельность (ВК РФ ст. 28; ICAO Doc 9734)</h3>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
<StatCard label="Аудитов за 30 дн." value={overview.audits_last_30d} color="primary" sub="Инспекции и проверки" />
<StatCard label="Организации" value={overview.organizations.total} sub="Подконтрольные субъекты" />
</div>
</section>
<LegalBasisBadge items={overview.legal_basis || []} />
</div>
)}
{/* === AIRCRAFT REGISTER TAB === */}
{tab === 'aircraft' && (
<div>
<p className="text-xs text-gray-500 mb-3">Данные аналогичны ФГИС РЭВС (приказ Росавиации 180-П от 09.03.2017)</p>
{aircraftData?.items?.length ? (
<DataTable
columns={[
{ key: 'registration_number', label: 'Рег. знак' },
{ key: 'aircraft_type', label: 'Тип ВС' },
{ key: 'status', label: 'Статус', render: (v: string) => (
<StatusBadge status={v} colorMap={{ active: 'bg-green-500', grounded: 'bg-red-500', maintenance: 'bg-yellow-500', decommissioned: 'bg-gray-400' }}
labelMap={{ active: 'Годен', grounded: 'Приостановлен', maintenance: 'На ТО', decommissioned: 'Списан' }} />
)},
{ key: 'organization', label: 'Эксплуатант' },
{ key: 'cert_expiry', label: 'СЛГ до', render: (v: string) => v ? new Date(v).toLocaleDateString('ru-RU') : '—' },
]}
data={aircraftData.items}
/>
) : <EmptyState message="Нет данных в реестре" />}
<LegalBasisBadge items={['ВК РФ ст. 33', 'ФГИС РЭВС (приказ № 180-П)', 'ICAO Annex 7']} />
</div>
)}
{/* === CERTIFICATIONS TAB === */}
{tab === 'certifications' && (
<div>
<p className="text-xs text-gray-500 mb-3">Процедуры подтверждения соответствия по ФАП-246</p>
{certData?.items?.length ? (
<DataTable
columns={[
{ key: 'id', label: '№', render: (v: string) => v?.slice(0, 8) },
{ key: 'type', label: 'Тип' },
{ key: 'status', label: 'Статус', render: (v: string) => (
<StatusBadge status={v} colorMap={{ pending: 'bg-yellow-500', approved: 'bg-green-500', rejected: 'bg-red-500', draft: 'bg-gray-400' }}
labelMap={{ pending: 'На рассмотрении', approved: 'Одобрена', rejected: 'Отклонена', draft: 'Черновик' }} />
)},
{ key: 'organization', label: 'Организация' },
{ key: 'submitted_at', label: 'Дата подачи', render: (v: string) => v ? new Date(v).toLocaleDateString('ru-RU') : '—' },
]}
data={certData.items}
/>
) : <EmptyState message="Нет заявок на сертификацию" />}
<LegalBasisBadge items={['ФАП-246 (приказ Минтранса № 246)', 'ICAO Annex 6 Part I', 'EASA Part-ORO']} />
</div>
)}
{/* === SAFETY TAB === */}
{tab === 'safety' && (
<div className="space-y-4">
<div className="flex gap-2 items-center mb-2">
<span className="text-xs text-gray-500">Период:</span>
{[30, 90, 180, 365].map(d => (
<button key={d} onClick={() => { setDays(d); setSafetyData(null); }}
className={`px-2.5 py-1 rounded text-xs ${days === d ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600'}`}>
{d}д
</button>
))}
</div>
{safetyData ? (
<>
{/* Severity distribution */}
<div className="card p-4">
<h4 className="text-sm font-bold text-gray-600 mb-3">Распределение рисков по степени серьёзности</h4>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{Object.entries(safetyData.severity_distribution || {}).map(([sev, cnt]) => (
<StatCard key={sev} label={sev} value={cnt as number}
color={sev === 'critical' ? 'red' : sev === 'high' ? 'yellow' : sev === 'medium' ? 'primary' : 'gray'} />
))}
</div>
</div>
{/* Monthly trend */}
{safetyData.monthly_trend?.length > 0 && (
<div className="card p-4">
<h4 className="text-sm font-bold text-gray-600 mb-3">Динамика рисков по месяцам</h4>
<div className="flex items-end gap-2 h-32">
{safetyData.monthly_trend.map((m: any, i: number) => {
const maxC = Math.max(...safetyData.monthly_trend.map((t: any) => t.count), 1);
return (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<span className="text-[10px] text-gray-500">{m.count}</span>
<div className="w-full bg-blue-500 rounded-t transition-all"
style={{ height: `${(m.count / maxC) * 100}%`, minHeight: '4px' }} />
<span className="text-[9px] text-gray-400 truncate w-full text-center">
{m.month ? new Date(m.month).toLocaleDateString('ru-RU', { month: 'short' }) : '?'}
</span>
</div>
);
})}
</div>
</div>
)}
<div className="card p-4 bg-red-50 border-red-200">
<div className="flex items-center gap-2">
<span className="text-2xl"></span>
<div>
<div className="text-lg font-bold text-red-700">{safetyData.critical_unresolved}</div>
<div className="text-xs text-red-600">Критических неустранённых рисков</div>
</div>
</div>
</div>
</>
) : <div className="text-center py-8 text-gray-400">Загрузка...</div>}
<LegalBasisBadge items={['ВК РФ ст. 24.1 (ГПБП)', 'ICAO Annex 19', 'ICAO Doc 9859 (SMM)', 'EASA Part-ORO.GEN.200']} />
</div>
)}
{/* === AUDITS TAB === */}
{tab === 'audits' && (
<div>
<p className="text-xs text-gray-500 mb-3">Результаты инспекционного контроля (ВК РФ ст. 28)</p>
{auditData?.items?.length ? (
<DataTable
columns={[
{ key: 'id', label: '№', render: (v: string) => v?.slice(0, 8) },
{ key: 'type', label: 'Тип проверки' },
{ key: 'status', label: 'Результат', render: (v: string) => (
<StatusBadge status={v} colorMap={{ completed: 'bg-green-500', open: 'bg-yellow-500', failed: 'bg-red-500' }}
labelMap={{ completed: 'Завершён', open: 'В процессе', failed: 'Замечания' }} />
)},
{ key: 'aircraft_reg', label: 'Рег. знак ВС' },
{ key: 'conducted_at', label: 'Дата', render: (v: string) => v ? new Date(v).toLocaleDateString('ru-RU') : '—' },
]}
data={auditData.items}
/>
) : <EmptyState message="Нет данных об аудитах" />}
<LegalBasisBadge items={['ВК РФ ст. 28', 'ICAO Doc 9734 (Safety Oversight Manual)', 'EASA Part-ARO.GEN.300']} />
</div>
)}
</>
)}
{/* === PERSONNEL PLG TAB === */}
{tab === 'personnel' && (
<div className="space-y-4">
<p className="text-xs text-gray-500 mb-3">Агрегированные данные о персонале ПЛГ (ВК РФ ст. 52-54; ФАП-147; ICAO Annex 1)</p>
{personnelData ? (
<>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="card p-4 text-center"><div className="text-3xl font-bold">{personnelData.total_specialists}</div><div className="text-xs text-gray-500">Всего специалистов</div></div>
<div className="card p-4 text-center bg-green-50"><div className="text-3xl font-bold text-green-600">{personnelData.compliant}</div><div className="text-xs text-green-600">Квалификация в порядке</div></div>
<div className="card p-4 text-center bg-red-50"><div className="text-3xl font-bold text-red-600">{personnelData.non_compliant}</div><div className="text-xs text-red-600">Нарушения</div></div>
<div className="card p-4 text-center bg-blue-50"><div className="text-3xl font-bold text-blue-600">{personnelData.compliance_rate}%</div><div className="text-xs text-blue-600">Compliance rate</div></div>
</div>
{Object.keys(personnelData.by_category || {}).length > 0 && (
<div className="card p-4">
<h4 className="text-sm font-bold text-gray-600 mb-3">Распределение по категориям (EASA Part-66 / ФАП-147)</h4>
<div className="grid grid-cols-4 gap-2">
{Object.entries(personnelData.by_category).map(([cat, cnt]) => (
<div key={cat} className="text-center p-2 rounded bg-gray-50">
<div className="text-lg font-bold">{cnt as number}</div>
<div className="text-xs text-gray-500">Кат. {cat}</div>
</div>
))}
</div>
</div>
)}
<div className="text-[10px] text-gray-400">Персональные данные (ФИО, табельные номера) не раскрываются (ФЗ-152)</div>
</>
) : <div className="text-center py-8 text-gray-400">Загрузка...</div>}
</div>
)}
{/* Footer */}
<div className="mt-8 pt-4 border-t border-gray-100 text-[10px] text-gray-400 space-y-1">
<div>Данные предоставлены из АСУ ТК КЛГ согласно ТЗ (утв. 24.07.2022) и Поручению Президента Пр-1379. Агрегированные показатели коммерческая тайна и ПДн не раскрываются.</div>
<div>Система соответствует требованиям ФЗ-152 «О персональных данных» и ФЗ-149 «Об информации».</div>
<div>© {new Date().getFullYear()} АО «REFLY» Разработчик АСУ ТК</div>
</div>
</PageLayout>
);
}

149
apply-updates.sh Executable file
View File

@ -0,0 +1,149 @@
#!/bin/bash
# КЛГ АСУ ТК — Скрипт применения обновлений v27
# Использование: ./apply-updates.sh /path/to/your/repo
set -e
TARGET="${1:-.}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "╔══════════════════════════════════════════════════╗"
echo "║ КЛГ АСУ ТК v27 — Применение обновлений ║"
echo "╚══════════════════════════════════════════════════╝"
echo ""
echo "Target: $TARGET"
echo ""
# Check target is git repo
if [ ! -d "$TARGET/.git" ]; then
echo "$TARGET не является git-репозиторием"
echo " Использование: ./apply-updates.sh /path/to/repo"
exit 1
fi
cd "$TARGET"
# Safety: create backup branch
BRANCH="backup-before-v27-$(date +%Y%m%d-%H%M%S)"
echo "📦 Создание резервной ветки: $BRANCH"
git checkout -b "$BRANCH"
git checkout -
echo ""
echo "📁 Копирование новых файлов..."
# === NEW FILES ===
# Backend services
mkdir -p backend/app/services
cp "$SCRIPT_DIR/backend/app/services/fgis_revs.py" backend/app/services/
cp "$SCRIPT_DIR/backend/app/services/ws_manager.py" backend/app/services/
# Backend routes
cp "$SCRIPT_DIR/backend/app/api/routes/fgis_revs.py" backend/app/api/routes/
cp "$SCRIPT_DIR/backend/app/api/routes/global_search.py" backend/app/api/routes/
cp "$SCRIPT_DIR/backend/app/api/routes/import_export.py" backend/app/api/routes/
cp "$SCRIPT_DIR/backend/app/api/routes/notification_prefs.py" backend/app/api/routes/
# Backend models
cp "$SCRIPT_DIR/backend/app/models/personnel_plg.py" backend/app/models/
cp "$SCRIPT_DIR/backend/app/models/airworthiness_core.py" backend/app/models/
cp "$SCRIPT_DIR/backend/app/models/work_orders.py" backend/app/models/
# Backend tests
cp "$SCRIPT_DIR/backend/tests/test_fgis_revs.py" backend/tests/
cp "$SCRIPT_DIR/backend/tests/test_global_search.py" backend/tests/
cp "$SCRIPT_DIR/backend/tests/test_import_export.py" backend/tests/
cp "$SCRIPT_DIR/backend/tests/test_notification_prefs.py" backend/tests/
cp "$SCRIPT_DIR/backend/tests/test_wo_integration.py" backend/tests/
# Dockerfiles
cp "$SCRIPT_DIR/backend/Dockerfile" backend/
cp "$SCRIPT_DIR/Dockerfile" .
cp "$SCRIPT_DIR/Makefile" .
# Frontend pages
for dir in fgis-revs calendar settings profile help; do
mkdir -p "app/$dir"
cp "$SCRIPT_DIR/app/$dir/page.tsx" "app/$dir/"
done
mkdir -p app/print/crs
cp "$SCRIPT_DIR/app/print/crs/page.tsx" app/print/crs/
# Components
cp "$SCRIPT_DIR/components/GlobalSearch.tsx" components/
cp "$SCRIPT_DIR/components/NotificationBell.tsx" components/
cp "$SCRIPT_DIR/components/AttachmentUpload.tsx" components/
cp "$SCRIPT_DIR/components/ShortcutsHelp.tsx" components/
cp "$SCRIPT_DIR/components/Breadcrumbs.tsx" components/
# Hooks & lib
cp "$SCRIPT_DIR/hooks/useKeyboardShortcuts.ts" hooks/
cp "$SCRIPT_DIR/lib/validation.ts" lib/
# PWA
cp "$SCRIPT_DIR/public/sw.js" public/
echo ""
echo "📝 Копирование изменённых файлов..."
# === MODIFIED FILES ===
cp "$SCRIPT_DIR/backend/app/main.py" backend/app/
cp "$SCRIPT_DIR/backend/app/api/routes/work_orders.py" backend/app/api/routes/
cp "$SCRIPT_DIR/backend/app/api/routes/defects.py" backend/app/api/routes/
cp "$SCRIPT_DIR/backend/app/api/routes/airworthiness_core.py" backend/app/api/routes/
cp "$SCRIPT_DIR/backend/app/api/routes/regulator.py" backend/app/api/routes/
cp "$SCRIPT_DIR/backend/app/api/routes/health.py" backend/app/api/routes/
cp "$SCRIPT_DIR/backend/app/api/routes/aircraft.py" backend/app/api/routes/
cp "$SCRIPT_DIR/backend/app/models/__init__.py" backend/app/models/
cp "$SCRIPT_DIR/backend/app/services/risk_scheduler.py" backend/app/services/
cp "$SCRIPT_DIR/app/dashboard/page.tsx" app/dashboard/
cp "$SCRIPT_DIR/app/airworthiness/page.tsx" app/airworthiness/
cp "$SCRIPT_DIR/app/audit-history/page.tsx" app/audit-history/
cp "$SCRIPT_DIR/app/maintenance/page.tsx" app/maintenance/
cp "$SCRIPT_DIR/app/defects/page.tsx" app/defects/
cp "$SCRIPT_DIR/app/risks/page.tsx" app/risks/
cp "$SCRIPT_DIR/app/inbox/page.tsx" app/inbox/
cp "$SCRIPT_DIR/app/applications/page.tsx" app/applications/
cp "$SCRIPT_DIR/app/modifications/page.tsx" app/modifications/
cp "$SCRIPT_DIR/components/Sidebar.tsx" components/
cp "$SCRIPT_DIR/components/ui/DataTable.tsx" components/ui/
cp "$SCRIPT_DIR/components/ui/PageLayout.tsx" components/ui/
cp "$SCRIPT_DIR/docker-compose.yml" .
cp "$SCRIPT_DIR/README.md" .
cp "$SCRIPT_DIR/CHANGELOG.md" .
cp "$SCRIPT_DIR/DEPLOY.md" .
cp "$SCRIPT_DIR/.gitignore" .
cp "$SCRIPT_DIR/e2e/smoke.spec.ts" e2e/
# CI/CD
mkdir -p .github/workflows
cp "$SCRIPT_DIR/.github/workflows/ci.yml" .github/workflows/
echo ""
echo "✅ Все файлы скопированы"
echo ""
echo "📋 Следующие шаги:"
echo ""
echo " 1. Проверить изменения:"
echo " git diff --stat"
echo ""
echo " 2. Закоммитить:"
echo " git add -A"
echo " git commit -m 'v27: ФГИС РЭВС + production hardening'"
echo ""
echo " 3. Запушить:"
echo " git push origin main"
echo ""
echo " 4. Применить миграции (если есть БД):"
echo " make migrate"
echo ""
echo " 5. Перезапустить:"
echo " make docker-rebuild # Docker"
echo " # или"
echo " make dev # Development"
echo ""
echo " Резервная ветка: $BRANCH"
echo " Откат: git checkout $BRANCH"

View File

@ -0,0 +1,47 @@
"""Initial schema + audit_log table
Revision ID: 0001
Revises:
Create Date: 2026-02-13
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = '0001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
"""
NOTE: This migration handles ONLY the audit_log table.
All other tables are created by SQLAlchemy create_all() on startup.
Run `alembic revision --autogenerate -m "Full schema"` after first deploy
to capture the complete schema in Alembic.
"""
op.create_table(
'audit_log',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('user_id', sa.String(36), nullable=True),
sa.Column('user_email', sa.String(255), nullable=True),
sa.Column('user_role', sa.String(50), nullable=True),
sa.Column('organization_id', sa.String(36), nullable=True),
sa.Column('action', sa.String(50), nullable=False),
sa.Column('entity_type', sa.String(100), nullable=False),
sa.Column('entity_id', sa.String(36), nullable=True),
sa.Column('changes', sa.JSON(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('ip_address', sa.String(45), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# Indexes for RLS and queries
op.create_index('idx_audit_log_org', 'audit_log', ['organization_id'])
op.create_index('idx_audit_log_entity', 'audit_log', ['entity_type', 'entity_id'])
op.create_index('idx_audit_log_user', 'audit_log', ['user_id'])
op.create_index('idx_audit_log_created', 'audit_log', ['created_at'])
def downgrade() -> None:
op.drop_table('audit_log')

109
backend/app/api/oidc.py Normal file
View File

@ -0,0 +1,109 @@
"""
OIDC JWT verification validates Keycloak tokens.
Falls back to DEV auth when OIDC is not configured.
"""
import logging
import os
from functools import lru_cache
from typing import Optional
import httpx
from jose import jwt, JWTError, jwk
logger = logging.getLogger(__name__)
OIDC_ISSUER = os.getenv("OIDC_ISSUER", "")
OIDC_AUDIENCE = os.getenv("OIDC_AUDIENCE", "klg-frontend")
_jwks_cache: dict = {}
@lru_cache(maxsize=1)
def get_oidc_config() -> Optional[dict]:
"""Fetch OIDC well-known configuration."""
if not OIDC_ISSUER:
return None
try:
resp = httpx.get(f"{OIDC_ISSUER}/.well-known/openid-configuration", timeout=5)
return resp.json()
except Exception as e:
logger.error(f"Failed to fetch OIDC config: {e}")
return None
def get_jwks() -> dict:
"""Fetch JSON Web Key Set from Keycloak."""
global _jwks_cache
if _jwks_cache:
return _jwks_cache
config = get_oidc_config()
if not config:
return {}
try:
resp = httpx.get(config["jwks_uri"], timeout=5)
_jwks_cache = resp.json()
return _jwks_cache
except Exception as e:
logger.error(f"Failed to fetch JWKS: {e}")
return {}
def verify_oidc_token(token: str) -> Optional[dict]:
"""
Verify and decode a Keycloak JWT token.
Returns decoded claims or None if invalid.
"""
if not OIDC_ISSUER:
return None # OIDC not configured
jwks = get_jwks()
if not jwks or "keys" not in jwks:
logger.warning("No JWKS keys available")
return None
try:
# Get key ID from token header
unverified = jwt.get_unverified_header(token)
kid = unverified.get("kid")
# Find matching key
key = None
for k in jwks["keys"]:
if k.get("kid") == kid:
key = k
break
if not key:
logger.warning(f"No matching key found for kid={kid}")
return None
# Verify and decode
claims = jwt.decode(
token,
key,
algorithms=["RS256"],
issuer=OIDC_ISSUER,
audience=OIDC_AUDIENCE,
options={"verify_aud": False}, # Keycloak may not include aud
)
return claims
except JWTError as e:
logger.warning(f"JWT verification failed: {e}")
return None
def extract_user_from_claims(claims: dict) -> dict:
"""Extract user info from JWT claims."""
roles = []
if "realm_access" in claims:
roles = claims["realm_access"].get("roles", [])
return {
"id": claims.get("sub", ""),
"email": claims.get("email", ""),
"display_name": claims.get("name", claims.get("preferred_username", "")),
"role": roles[0] if roles else "operator_user",
"roles": roles,
"organization_id": claims.get("organization_id"),
}

View File

@ -0,0 +1,343 @@
"""
Ядро системы ПЛГ контроль лётной годности ВС.
Модули:
1. Директивы лётной годности (AD/ДЛГ) ВК РФ ст. 37; ФАП-148 п.4.3; EASA Part-M.A.301; ICAO Annex 8
2. Сервисные бюллетени (SB) ФАП-148 п.4.5; EASA Part-M.A.301; EASA Part-21.A.3B
3. Ресурсы и сроки службы (Life Limits) ФАП-148 п.4.2; EASA Part-M.A.302; ICAO Annex 8 Part II 4.2
4. Программы ТО (MP) ФАП-148 п.3; EASA Part-M.A.302; ICAO Annex 6 Part I 8.3
5. Карточки компонентов ФАП-145 п.145.A.42; EASA Part-M.A.501
"""
import uuid
import logging
from datetime import datetime, timezone, timedelta
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_user
from app.api.helpers import audit
import asyncio
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/airworthiness-core", tags=["airworthiness-core"])
# ===================================================================
# IN-MEMORY STORAGE (prod: PostgreSQL)
# ===================================================================
_directives: dict = {}
_bulletins: dict = {}
_life_limits: dict = {}
_maint_programs: dict = {}
_components: dict = {}
# ===================================================================
# 1. ДИРЕКТИВЫ ЛЁТНОЙ ГОДНОСТИ (AD / ДЛГ)
# ===================================================================
class DirectiveCreate(BaseModel):
number: str = Field(..., description="Номер ДЛГ (напр. AD 2025-01-15R1)")
title: str
issuing_authority: str = Field("FATA", description="ФАВТ / EASA / FAA / другой")
aircraft_types: List[str] = Field(default=[])
ata_chapter: Optional[str] = Field(None, description="ATA Chapter (напр. 32 Landing Gear)")
effective_date: str
compliance_type: str = Field("mandatory", description="mandatory | recommended | info")
compliance_deadline: Optional[str] = None
repetitive: bool = False
repetitive_interval_hours: Optional[float] = None
repetitive_interval_days: Optional[int] = None
description: str = ""
affected_parts: List[str] = Field(default=[], description="P/N затронутых компонентов")
supersedes: Optional[str] = None
status: str = Field("open", description="open | complied | not_applicable | deferred")
@router.get("/directives")
def list_directives(
status: Optional[str] = None,
aircraft_type: Optional[str] = None,
user=Depends(get_current_user),
):
"""Реестр директив лётной годности (AD/ДЛГ)."""
items = list(_directives.values())
if status:
items = [d for d in items if d["status"] == status]
if aircraft_type:
items = [d for d in items if aircraft_type in d.get("aircraft_types", [])]
return {"total": len(items), "items": items,
"legal_basis": "ВК РФ ст. 37; ФАП-148 п.4.3; EASA Part-M.A.301; ICAO Annex 8"}
@router.post("/directives")
def create_directive(data: DirectiveCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Зарегистрировать директиву ЛГ."""
did = str(uuid.uuid4())
d = {"id": did, **data.dict(), "created_at": datetime.now(timezone.utc).isoformat()}
_directives[did] = d
if data.compliance_type == "mandatory":
try:
from app.services.ws_manager import notify_new_ad
asyncio.create_task(notify_new_ad(data.number, data.aircraft_types, data.compliance_type))
except Exception:
pass
audit(db, user, "create", "directive", entity_id=did, description=f"ДЛГ: {data.number}")
db.commit()
return d
@router.get("/directives/{directive_id}")
def get_directive(directive_id: str, user=Depends(get_current_user)):
d = _directives.get(directive_id)
if not d: raise HTTPException(404, "Directive not found")
return d
@router.put("/directives/{directive_id}/comply")
def comply_directive(directive_id: str, compliance_date: str = "", notes: str = "",
db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Отметить выполнение ДЛГ."""
d = _directives.get(directive_id)
if not d: raise HTTPException(404)
d["status"] = "complied"
d["compliance_date"] = compliance_date or datetime.now(timezone.utc).isoformat()
d["compliance_notes"] = notes
audit(db, user, "comply", "directive", entity_id=directive_id, description=f"ДЛГ выполнена: {d['number']}")
db.commit()
return d
# ===================================================================
# 2. СЕРВИСНЫЕ БЮЛЛЕТЕНИ (SB)
# ===================================================================
class BulletinCreate(BaseModel):
number: str = Field(..., description="Номер SB (напр. SB-737-32-1456)")
title: str
manufacturer: str = Field(..., description="ОКБ / OEM")
aircraft_types: List[str] = Field(default=[])
ata_chapter: Optional[str] = None
category: str = Field("recommended", description="mandatory | alert | recommended | info")
issued_date: str
compliance_deadline: Optional[str] = None
estimated_manhours: Optional[float] = None
description: str = ""
related_ad: Optional[str] = Field(None, description="Связанная ДЛГ")
status: str = Field("open", description="open | incorporated | not_applicable | deferred")
@router.get("/bulletins")
def list_bulletins(status: Optional[str] = None, user=Depends(get_current_user)):
items = list(_bulletins.values())
if status: items = [b for b in items if b["status"] == status]
return {"total": len(items), "items": items,
"legal_basis": "ФАП-148 п.4.5; EASA Part-M.A.301; Part-21.A.3B"}
@router.post("/bulletins")
def create_bulletin(data: BulletinCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
bid = str(uuid.uuid4())
b = {"id": bid, **data.dict(), "created_at": datetime.now(timezone.utc).isoformat()}
_bulletins[bid] = b
audit(db, user, "create", "bulletin", entity_id=bid, description=f"SB: {data.number}")
db.commit()
return b
@router.put("/bulletins/{bulletin_id}/incorporate")
def incorporate_bulletin(bulletin_id: str, date: str = "", notes: str = "",
db: Session = Depends(get_db), user=Depends(get_current_user)):
b = _bulletins.get(bulletin_id)
if not b: raise HTTPException(404)
b["status"] = "incorporated"
b["incorporation_date"] = date or datetime.now(timezone.utc).isoformat()
b["incorporation_notes"] = notes
audit(db, user, "incorporate", "bulletin", entity_id=bulletin_id, description=f"SB выполнен: {b['number']}")
db.commit()
return b
# ===================================================================
# 3. РЕСУРСЫ И СРОКИ СЛУЖБЫ (Life Limits)
# ===================================================================
class LifeLimitCreate(BaseModel):
aircraft_id: Optional[str] = None
component_name: str
part_number: str
serial_number: str
limit_type: str = Field(..., description="calendar | flight_hours | cycles | combined")
calendar_limit_months: Optional[int] = None
flight_hours_limit: Optional[float] = None
cycles_limit: Optional[int] = None
current_hours: float = 0
current_cycles: int = 0
install_date: Optional[str] = None
last_overhaul_date: Optional[str] = None
notes: Optional[str] = None
@router.get("/life-limits")
def list_life_limits(aircraft_id: Optional[str] = None, user=Depends(get_current_user)):
items = list(_life_limits.values())
if aircraft_id: items = [ll for ll in items if ll.get("aircraft_id") == aircraft_id]
# Calculate remaining
for ll in items:
remaining = {}
if ll.get("flight_hours_limit"):
remaining["hours"] = round(ll["flight_hours_limit"] - ll.get("current_hours", 0), 1)
if ll.get("cycles_limit"):
remaining["cycles"] = ll["cycles_limit"] - ll.get("current_cycles", 0)
if ll.get("calendar_limit_months") and ll.get("install_date"):
from dateutil.relativedelta import relativedelta
try:
install = datetime.fromisoformat(ll["install_date"])
expiry = install + timedelta(days=ll["calendar_limit_months"] * 30)
remaining["days"] = (expiry - datetime.now(timezone.utc).replace(tzinfo=None)).days
except:
pass
ll["remaining"] = remaining
ll["critical"] = any(v <= 0 for v in remaining.values() if isinstance(v, (int, float)))
return {"total": len(items), "items": items,
"legal_basis": "ФАП-148 п.4.2; EASA Part-M.A.302; ICAO Annex 8 Part II 4.2"}
@router.post("/life-limits")
def create_life_limit(data: LifeLimitCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
lid = str(uuid.uuid4())
ll = {"id": lid, **data.dict(), "created_at": datetime.now(timezone.utc).isoformat()}
_life_limits[lid] = ll
audit(db, user, "create", "life_limit", entity_id=lid, description=f"Ресурс: {data.component_name} P/N {data.part_number}")
db.commit()
return ll
@router.put("/life-limits/{limit_id}/update-usage")
def update_usage(limit_id: str, hours: Optional[float] = None, cycles: Optional[int] = None,
db: Session = Depends(get_db), user=Depends(get_current_user)):
ll = _life_limits.get(limit_id)
if not ll: raise HTTPException(404)
if hours is not None: ll["current_hours"] = hours
if cycles is not None: ll["current_cycles"] = cycles
audit(db, user, "update_usage", "life_limit", entity_id=limit_id)
db.commit()
return ll
# ===================================================================
# 4. ПРОГРАММЫ ТО (Maintenance Programs)
# ===================================================================
class MaintProgramCreate(BaseModel):
name: str
aircraft_type: str
revision: str = "Rev.0"
approved_by: Optional[str] = Field(None, description="Кем утверждена (ФАВТ / CAMO)")
approval_date: Optional[str] = None
tasks: List[dict] = Field(default=[], description="Список задач ТО с интервалами")
@router.get("/maintenance-programs")
def list_maint_programs(aircraft_type: Optional[str] = None, user=Depends(get_current_user)):
items = list(_maint_programs.values())
if aircraft_type: items = [m for m in items if m.get("aircraft_type") == aircraft_type]
return {"total": len(items), "items": items,
"legal_basis": "ФАП-148 п.3; EASA Part-M.A.302; ICAO Annex 6 Part I 8.3"}
@router.post("/maintenance-programs")
def create_maint_program(data: MaintProgramCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
mid = str(uuid.uuid4())
m = {"id": mid, **data.dict(), "created_at": datetime.now(timezone.utc).isoformat()}
_maint_programs[mid] = m
audit(db, user, "create", "maint_program", entity_id=mid, description=f"Программа ТО: {data.name}")
db.commit()
return m
@router.get("/maintenance-programs/{program_id}")
def get_maint_program(program_id: str, user=Depends(get_current_user)):
m = _maint_programs.get(program_id)
if not m: raise HTTPException(404)
return m
# ===================================================================
# 5. КАРТОЧКИ КОМПОНЕНТОВ (Component Cards)
# ===================================================================
class ComponentCreate(BaseModel):
name: str
part_number: str
serial_number: str
aircraft_id: Optional[str] = None
ata_chapter: Optional[str] = None
manufacturer: Optional[str] = None
install_date: Optional[str] = None
install_position: Optional[str] = None
current_hours: float = 0
current_cycles: int = 0
condition: str = Field("serviceable", description="serviceable | unserviceable | overhauled | scrapped")
certificate_type: Optional[str] = Field(None, description="EASA Form 1 / FAA 8130-3 / ФАП-145 Форма 1")
certificate_number: Optional[str] = None
last_shop_visit: Optional[str] = None
next_overhaul_due: Optional[str] = None
notes: Optional[str] = None
@router.get("/components")
def list_components(aircraft_id: Optional[str] = None, condition: Optional[str] = None,
page: int = Query(1, ge=1), per_page: int = Query(50, le=200),
user=Depends(get_current_user)):
items = list(_components.values())
if aircraft_id: items = [c for c in items if c.get("aircraft_id") == aircraft_id]
if condition: items = [c for c in items if c.get("condition") == condition]
total = len(items)
start = (page - 1) * per_page
return {"total": total, "page": page, "items": items[start:start + per_page],
"legal_basis": "ФАП-145 п.145.A.42; EASA Part-M.A.501; EASA Part-M.A.307"}
@router.post("/components")
def create_component(data: ComponentCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
cid = str(uuid.uuid4())
c = {"id": cid, **data.dict(), "created_at": datetime.now(timezone.utc).isoformat()}
_components[cid] = c
audit(db, user, "create", "component", entity_id=cid, description=f"Компонент: {data.name} S/N {data.serial_number}")
db.commit()
return c
@router.get("/components/{component_id}")
def get_component(component_id: str, user=Depends(get_current_user)):
c = _components.get(component_id)
if not c: raise HTTPException(404)
return c
@router.put("/components/{component_id}/transfer")
def transfer_component(component_id: str, new_aircraft_id: str = "", position: str = "",
db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Перемещение компонента между ВС (Part-M.A.501)."""
c = _components.get(component_id)
if not c: raise HTTPException(404)
old_aircraft = c.get("aircraft_id", "склад")
c["aircraft_id"] = new_aircraft_id or None
c["install_position"] = position
c["install_date"] = datetime.now(timezone.utc).isoformat()
audit(db, user, "transfer", "component", entity_id=component_id,
description=f"Компонент {c['name']} S/N {c['serial_number']}: {old_aircraft}{new_aircraft_id or 'склад'}")
db.commit()
return c
# ===================================================================
# 6. СВОДНЫЙ ОТЧЁТ ПО ЛГ КОНКРЕТНОГО ВС
# ===================================================================
@router.get("/aircraft-status/{aircraft_reg}")
def aircraft_airworthiness_status(aircraft_reg: str, user=Depends(get_current_user)):
"""Полный статус лётной годности конкретного ВС."""
open_ads = [d for d in _directives.values() if d["status"] == "open"]
open_sbs = [b for b in _bulletins.values() if b["status"] == "open"]
critical_ll = [ll for ll in _life_limits.values()
if ll.get("remaining", {}) and any(v <= 0 for v in ll["remaining"].values() if isinstance(v, (int, float)))]
components = [c for c in _components.values() if c.get("aircraft_id")]
return {
"aircraft": aircraft_reg,
"generated_at": datetime.now(timezone.utc).isoformat(),
"summary": {
"open_directives": len(open_ads),
"open_bulletins": len(open_sbs),
"critical_life_limits": len(critical_ll),
"installed_components": len(components),
},
"airworthy": len(open_ads) == 0 and len(critical_ll) == 0,
"legal_basis": "ВК РФ ст. 36, 37, 37.2; ФАП-148; EASA Part-M.A.901; ICAO Annex 8",
}

View File

@ -0,0 +1,76 @@
"""
Data backup/restore export all data as JSON, import from backup.
Admin only. Production: use pg_dump for full backups.
"""
import json
from datetime import datetime
from fastapi import APIRouter, Depends, Response, UploadFile, File
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, require_roles, get_db
from app.api.helpers import audit
from app.models import Aircraft, Organization, CertApplication, RiskAlert, Audit
router = APIRouter(prefix="/backup", tags=["backup"])
BACKUP_MODELS = {
"aircraft": Aircraft,
"organizations": Organization,
"cert_applications": CertApplication,
"risk_alerts": RiskAlert,
"audits": Audit,
}
def serialize_row(row) -> dict:
d = {}
for col in row.__table__.columns:
val = getattr(row, col.name, None)
if isinstance(val, datetime):
val = val.isoformat()
d[col.name] = val
return d
@router.get(
"/export",
dependencies=[Depends(require_roles("admin"))],
)
def backup_export(db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Export all data as JSON backup."""
backup = {
"version": "2.1.0",
"created_at": datetime.utcnow().isoformat(),
"created_by": user.email,
"data": {},
}
total = 0
for name, model in BACKUP_MODELS.items():
rows = db.query(model).all()
backup["data"][name] = [serialize_row(r) for r in rows]
total += len(rows)
backup["total_records"] = total
audit(db, user, "backup", "system", description=f"Exported backup: {total} records")
db.commit()
return Response(
content=json.dumps(backup, ensure_ascii=False, indent=2, default=str),
media_type="application/json",
headers={
"Content-Disposition": f"attachment; filename=klg_backup_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
},
)
@router.get(
"/stats",
dependencies=[Depends(require_roles("admin"))],
)
def backup_stats(db: Session = Depends(get_db)):
"""Get record counts for backup preview."""
stats = {}
for name, model in BACKUP_MODELS.items():
stats[name] = db.query(model).count()
return {"tables": stats, "total": sum(stats.values())}

View File

@ -0,0 +1,78 @@
"""
Batch operations API bulk create/update/delete.
Reduces N+1 API calls for bulk operations.
"""
from typing import List
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, require_roles, get_db
from app.api.helpers import audit
from app.models import Aircraft, Organization, RiskAlert
router = APIRouter(prefix="/batch", tags=["batch"])
class BatchDeleteRequest(BaseModel):
entity_type: str # aircraft, organizations, risk_alerts
ids: List[str]
class BatchStatusUpdate(BaseModel):
entity_type: str
ids: List[str]
status: str
ENTITY_MAP = {
"aircraft": Aircraft,
"organizations": Organization,
"risk_alerts": RiskAlert,
}
@router.post(
"/delete",
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
)
def batch_delete(req: BatchDeleteRequest, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Delete multiple entities in one request."""
model = ENTITY_MAP.get(req.entity_type)
if not model:
return {"error": f"Unknown entity: {req.entity_type}", "deleted": 0}
deleted = 0
for eid in req.ids[:100]: # max 100 per batch
obj = db.query(model).filter(model.id == eid).first()
if obj:
db.delete(obj)
deleted += 1
audit(db, user, "batch_delete", req.entity_type,
description=f"Batch deleted {deleted}/{len(req.ids)} {req.entity_type}")
db.commit()
return {"deleted": deleted, "total_requested": len(req.ids)}
@router.post(
"/status",
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
)
def batch_status_update(req: BatchStatusUpdate, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Update status of multiple entities."""
model = ENTITY_MAP.get(req.entity_type)
if not model or not hasattr(model, "status"):
return {"error": f"Unknown entity or no status field: {req.entity_type}", "updated": 0}
updated = 0
for eid in req.ids[:100]:
obj = db.query(model).filter(model.id == eid).first()
if obj:
obj.status = req.status
updated += 1
audit(db, user, "batch_update", req.entity_type,
description=f"Batch status→{req.status} for {updated}/{len(req.ids)} {req.entity_type}")
db.commit()
return {"updated": updated, "status": req.status}

View File

@ -0,0 +1,89 @@
"""
Дефекты и неисправности ВС.
ВК РФ ст. 37.2; ФАП-145 п.145.A.50; EASA Part-M.A.403; ICAO Annex 8 Part II 4.2.3
"""
import uuid, logging
from datetime import datetime, timezone
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_user
from app.api.helpers import audit
import asyncio
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/defects", tags=["defects"])
_defects: dict = {}
class DefectCreate(BaseModel):
aircraft_reg: str
ata_chapter: Optional[str] = None
description: str
severity: str = Field("minor", description="critical | major | minor")
discovered_by: Optional[str] = None
discovered_during: str = Field("preflight", description="preflight | transit | daily | a_check | c_check | report")
component_pn: Optional[str] = None
component_sn: Optional[str] = None
mel_reference: Optional[str] = Field(None, description="MEL/CDL reference если применимо")
deferred: bool = False
deferred_until: Optional[str] = None
corrective_action: Optional[str] = None
status: str = Field("open", description="open | deferred | rectified | closed")
@router.get("/")
def list_defects(status: Optional[str] = None, aircraft_reg: Optional[str] = None,
severity: Optional[str] = None, user=Depends(get_current_user)):
items = list(_defects.values())
if status: items = [d for d in items if d["status"] == status]
if aircraft_reg: items = [d for d in items if d["aircraft_reg"] == aircraft_reg]
if severity: items = [d for d in items if d["severity"] == severity]
return {"total": len(items), "items": items,
"legal_basis": "ФАП-145 п.145.A.50; EASA Part-M.A.403; ICAO Annex 8"}
@router.post("/")
def create_defect(data: DefectCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
did = str(uuid.uuid4())
d = {"id": did, **data.dict(), "created_at": datetime.now(timezone.utc).isoformat()}
_defects[did] = d
if data.severity == "critical":
try:
from app.services.ws_manager import notify_critical_defect
asyncio.create_task(notify_critical_defect(data.aircraft_reg, data.description, did))
except Exception:
pass
audit(db, user, "create", "defect", entity_id=did, description=f"Дефект: {data.aircraft_reg}{data.description[:60]}")
db.commit()
return d
@router.get("/{defect_id}")
def get_defect(defect_id: str, user=Depends(get_current_user)):
d = _defects.get(defect_id)
if not d: raise HTTPException(404)
return d
@router.put("/{defect_id}/rectify")
def rectify_defect(defect_id: str, action: str = "", db: Session = Depends(get_db), user=Depends(get_current_user)):
d = _defects.get(defect_id)
if not d: raise HTTPException(404)
d["status"] = "rectified"
d["corrective_action"] = action
d["rectified_at"] = datetime.now(timezone.utc).isoformat()
audit(db, user, "rectify", "defect", entity_id=defect_id, description=f"Дефект устранён: {d['aircraft_reg']}")
db.commit()
return d
@router.put("/{defect_id}/defer")
def defer_defect(defect_id: str, mel_ref: str = "", until: str = "",
db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Отложить дефект по MEL/CDL (EASA Part-M.A.403(c))."""
d = _defects.get(defect_id)
if not d: raise HTTPException(404)
d["status"] = "deferred"
d["deferred"] = True
d["mel_reference"] = mel_ref
d["deferred_until"] = until
audit(db, user, "defer", "defect", entity_id=defect_id, description=f"Дефект отложен по MEL: {mel_ref}")
db.commit()
return d

View File

@ -0,0 +1,84 @@
"""
Export endpoint CSV, JSON export of system data.
Production: add XLSX via openpyxl, PDF via reportlab.
"""
import csv
import io
import json
from datetime import datetime
from fastapi import APIRouter, Depends, Query, Response
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, require_roles, get_db
from app.api.helpers import audit
from app.models import Aircraft, Organization, CertApplication, RiskAlert, Audit
router = APIRouter(prefix="/export", tags=["export"])
EXPORTABLE = {
"aircraft": Aircraft,
"organizations": Organization,
"cert_applications": CertApplication,
"risk_alerts": RiskAlert,
"audits": Audit,
}
@router.get(
"/{dataset}",
dependencies=[Depends(require_roles("admin", "authority_inspector", "operator_manager"))],
)
def export_data(
dataset: str,
format: str = Query("csv", regex="^(csv|json)$"),
limit: int = Query(5000, le=50000),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Export dataset as CSV or JSON."""
model = EXPORTABLE.get(dataset)
if not model:
return Response(
content=json.dumps({"error": f"Unknown dataset: {dataset}. Available: {list(EXPORTABLE.keys())}"}),
media_type="application/json", status_code=400,
)
rows = db.query(model).limit(limit).all()
audit(db, user, "export", dataset, description=f"Exported {len(rows)} {dataset} as {format}")
db.commit()
if format == "json":
data = []
for row in rows:
d = {}
for col in row.__table__.columns:
val = getattr(row, col.name, None)
if isinstance(val, datetime):
val = val.isoformat()
d[col.name] = val
data.append(d)
return Response(
content=json.dumps(data, ensure_ascii=False, indent=2, default=str),
media_type="application/json",
headers={"Content-Disposition": f"attachment; filename={dataset}_{datetime.utcnow().strftime('%Y%m%d')}.json"},
)
# CSV
output = io.StringIO()
if rows:
cols = [col.name for col in rows[0].__table__.columns]
writer = csv.DictWriter(output, fieldnames=cols)
writer.writeheader()
for row in rows:
d = {}
for col in cols:
val = getattr(row, col, None)
d[col] = val.isoformat() if isinstance(val, datetime) else val
writer.writerow(d)
return Response(
content=output.getvalue(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename={dataset}_{datetime.utcnow().strftime('%Y%m%d')}.csv"},
)

View File

@ -0,0 +1,53 @@
"""
Глобальный поиск по всем сущностям АСУ ТК.
Ищет по: ВС, компонентам, директивам, бюллетеням, нарядам, дефектам, персоналу.
"""
import logging
from fastapi import APIRouter, Depends, Query
from app.api.deps import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/search", tags=["search"])
@router.get("/global")
def global_search(q: str = Query(..., min_length=2), user=Depends(get_current_user)):
"""Поиск по всем модулям системы."""
q_lower = q.lower()
results = []
# Search in directives
from app.api.routes.airworthiness_core import _directives, _bulletins, _life_limits, _components
for d in _directives.values():
if q_lower in d.get("number", "").lower() or q_lower in d.get("title", "").lower():
results.append({"type": "directive", "id": d["id"], "title": f"ДЛГ {d['number']}", "subtitle": d.get("title", ""), "url": "/airworthiness-core"})
# Bulletins
for b in _bulletins.values():
if q_lower in b.get("number", "").lower() or q_lower in b.get("title", "").lower():
results.append({"type": "bulletin", "id": b["id"], "title": f"SB {b['number']}", "subtitle": b.get("title", ""), "url": "/airworthiness-core"})
# Components
for c in _components.values():
if q_lower in c.get("name", "").lower() or q_lower in c.get("part_number", "").lower() or q_lower in c.get("serial_number", "").lower():
results.append({"type": "component", "id": c["id"], "title": f"{c['name']} P/N {c['part_number']}", "subtitle": f"S/N {c['serial_number']}", "url": "/airworthiness-core"})
# Work orders
from app.api.routes.work_orders import _work_orders
for w in _work_orders.values():
if q_lower in w.get("wo_number", "").lower() or q_lower in w.get("title", "").lower() or q_lower in w.get("aircraft_reg", "").lower():
results.append({"type": "work_order", "id": w["id"], "title": f"WO {w['wo_number']}", "subtitle": w.get("title", ""), "url": "/maintenance"})
# Defects
from app.api.routes.defects import _defects
for d in _defects.values():
if q_lower in d.get("aircraft_reg", "").lower() or q_lower in d.get("description", "").lower():
results.append({"type": "defect", "id": d["id"], "title": f"Дефект {d['aircraft_reg']}", "subtitle": d.get("description", "")[:80], "url": "/defects"})
# Personnel
from app.api.routes.personnel_plg import _specialists
for s in _specialists.values():
if q_lower in s.get("full_name", "").lower() or q_lower in s.get("personnel_number", "").lower() or q_lower in s.get("license_number", "").lower():
results.append({"type": "specialist", "id": s["id"], "title": s["full_name"], "subtitle": f"Кат. {s['category']} · {s.get('license_number', '')}", "url": "/personnel-plg"})
return {"query": q, "total": len(results), "results": results[:50]}

View File

@ -0,0 +1,148 @@
"""
Import/Export Excel (XLSX) массовая загрузка и выгрузка данных.
Поддерживает: компоненты, директивы, персонал ПЛГ, дефекты.
"""
import io
import logging
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_user
from app.api.helpers import audit
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/import-export", tags=["import-export"])
@router.get("/export/{entity_type}")
def export_xlsx(entity_type: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""
Экспорт в XLSX. Типы: components, directives, bulletins, specialists, defects, work_orders.
"""
try:
import openpyxl
except ImportError:
raise HTTPException(500, "openpyxl not installed")
data_map = {
"components": ("airworthiness_core", "_components", ["name", "part_number", "serial_number", "ata_chapter", "manufacturer", "condition", "current_hours", "current_cycles"]),
"directives": ("airworthiness_core", "_directives", ["number", "title", "issuing_authority", "effective_date", "compliance_type", "status"]),
"bulletins": ("airworthiness_core", "_bulletins", ["number", "title", "manufacturer", "category", "issued_date", "status"]),
"specialists": ("personnel_plg", "_specialists", ["full_name", "personnel_number", "position", "category", "license_number", "status"]),
"defects": ("defects", "_defects", ["aircraft_reg", "ata_chapter", "description", "severity", "discovered_during", "status"]),
"work_orders": ("work_orders", "_work_orders", ["wo_number", "aircraft_reg", "wo_type", "title", "priority", "status", "estimated_manhours"]),
}
if entity_type not in data_map:
raise HTTPException(400, f"Unknown entity: {entity_type}. Supported: {list(data_map.keys())}")
module_name, store_name, columns = data_map[entity_type]
mod = __import__(f"app.api.routes.{module_name}", fromlist=[store_name])
items = list(getattr(mod, store_name).values())
wb = openpyxl.Workbook()
ws = wb.active
ws.title = entity_type
ws.append(columns)
for item in items:
row = [str(item.get(col, "")) for col in columns]
ws.append(row)
# Style header
from openpyxl.styles import Font, PatternFill
for cell in ws[1]:
cell.font = Font(bold=True)
cell.fill = PatternFill(start_color="DBEAFE", end_color="DBEAFE", fill_type="solid")
for col in ws.columns:
max_len = max(len(str(cell.value or "")) for cell in col)
ws.column_dimensions[col[0].column_letter].width = min(max_len + 2, 40)
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
audit(db, user, "export_xlsx", entity_type, description=f"XLSX export: {entity_type} ({len(items)} rows)")
db.commit()
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={entity_type}_{datetime.now().strftime('%Y%m%d')}.xlsx"},
)
@router.post("/import/{entity_type}")
async def import_xlsx(
entity_type: str,
file: UploadFile = File(...),
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Импорт из XLSX. Первая строка — заголовки."""
try:
import openpyxl
except ImportError:
raise HTTPException(500, "openpyxl not installed")
if not file.filename.endswith(('.xlsx', '.xls')):
raise HTTPException(400, "Only .xlsx files accepted")
content = await file.read()
wb = openpyxl.load_workbook(io.BytesIO(content))
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
if len(rows) < 2:
raise HTTPException(400, "File must have header + at least 1 data row")
headers = [str(h).strip().lower() if h else "" for h in rows[0]]
imported = 0
errors = []
for i, row in enumerate(rows[1:], start=2):
try:
item = {headers[j]: (str(v).strip() if v is not None else "") for j, v in enumerate(row) if j < len(headers)}
item["id"] = str(uuid.uuid4())
item["created_at"] = datetime.now(timezone.utc).isoformat()
if entity_type == "components":
from app.api.routes.airworthiness_core import _components
if not item.get("name") or not item.get("part_number") or not item.get("serial_number"):
errors.append(f"Row {i}: missing required fields (name, part_number, serial_number)")
continue
item.setdefault("condition", "serviceable")
item["current_hours"] = float(item.get("current_hours", 0) or 0)
item["current_cycles"] = int(item.get("current_cycles", 0) or 0)
_components[item["id"]] = item
elif entity_type == "specialists":
from app.api.routes.personnel_plg import _specialists
if not item.get("full_name") or not item.get("personnel_number"):
errors.append(f"Row {i}: missing full_name or personnel_number")
continue
item.setdefault("status", "active")
item.setdefault("specializations", [])
_specialists[item["id"]] = item
elif entity_type == "directives":
from app.api.routes.airworthiness_core import _directives
if not item.get("number") or not item.get("title"):
errors.append(f"Row {i}: missing number or title")
continue
item.setdefault("status", "open")
item.setdefault("aircraft_types", [])
_directives[item["id"]] = item
else:
errors.append(f"Import not supported for: {entity_type}")
break
imported += 1
except Exception as e:
errors.append(f"Row {i}: {str(e)}")
audit(db, user, "import_xlsx", entity_type, description=f"XLSX import: {entity_type} ({imported} imported, {len(errors)} errors)")
db.commit()
return {"imported": imported, "errors": errors[:20], "total_rows": len(rows) - 1}

View File

@ -0,0 +1,62 @@
"""
Prometheus-compatible metrics endpoint.
Exposes request counts, latencies, and business metrics.
"""
import time
from collections import defaultdict
from fastapi import APIRouter, Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
router = APIRouter(tags=["monitoring"])
# In-memory counters (production: use prometheus_client library)
_request_count: dict[str, int] = defaultdict(int)
_request_latency_sum: dict[str, float] = defaultdict(float)
_error_count: dict[str, int] = defaultdict(int)
class MetricsMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
start = time.monotonic()
response = await call_next(request)
elapsed = time.monotonic() - start
path = request.url.path
method = request.method
key = f"{method} {path}"
_request_count[key] += 1
_request_latency_sum[key] += elapsed
if response.status_code >= 400:
_error_count[f"{response.status_code}"] += 1
return response
@router.get("/metrics")
def prometheus_metrics():
"""Prometheus-compatible text metrics."""
lines = [
"# HELP klg_http_requests_total Total HTTP requests",
"# TYPE klg_http_requests_total counter",
]
for key, count in sorted(_request_count.items()):
method, path = key.split(" ", 1)
lines.append(f'klg_http_requests_total{{method="{method}",path="{path}"}} {count}')
lines += [
"# HELP klg_http_request_duration_seconds Total request duration",
"# TYPE klg_http_request_duration_seconds counter",
]
for key, total in sorted(_request_latency_sum.items()):
method, path = key.split(" ", 1)
lines.append(f'klg_http_request_duration_seconds{{method="{method}",path="{path}"}} {total:.4f}')
lines += [
"# HELP klg_http_errors_total Total HTTP errors by status code",
"# TYPE klg_http_errors_total counter",
]
for code, count in sorted(_error_count.items()):
lines.append(f'klg_http_errors_total{{status="{code}"}} {count}')
return Response(content="\n".join(lines) + "\n", media_type="text/plain")

View File

@ -0,0 +1,38 @@
"""
Настройки уведомлений пользователя.
Позволяет включать/отключать: email, push, WS для разных типов событий.
"""
import uuid
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from typing import Optional
from app.api.deps import get_current_user
router = APIRouter(prefix="/notification-preferences", tags=["notifications"])
_prefs: dict = {}
class NotificationPrefs(BaseModel):
ad_mandatory: bool = Field(True, description="Обязательные ДЛГ")
ad_recommended: bool = Field(False, description="Рекомендательные ДЛГ")
defect_critical: bool = Field(True, description="Критические дефекты")
defect_major: bool = Field(True, description="Значительные дефекты")
defect_minor: bool = Field(False, description="Незначительные дефекты")
wo_aog: bool = Field(True, description="AOG наряды")
wo_closed: bool = Field(True, description="Закрытие нарядов (CRS)")
life_limit_critical: bool = Field(True, description="Критические ресурсы")
personnel_expiry: bool = Field(True, description="Просрочка квалификации")
channels_email: bool = Field(True, description="Email уведомления")
channels_push: bool = Field(False, description="Push уведомления")
channels_ws: bool = Field(True, description="WebSocket real-time")
@router.get("/")
def get_preferences(user=Depends(get_current_user)):
uid = getattr(user, 'sub', 'default')
return _prefs.get(uid, NotificationPrefs().dict())
@router.put("/")
def update_preferences(data: NotificationPrefs, user=Depends(get_current_user)):
uid = getattr(user, 'sub', 'default')
_prefs[uid] = data.dict()
return _prefs[uid]

View File

@ -0,0 +1,577 @@
"""
Сертификация персонала ПЛГ учёт специалистов, аттестация, повышение квалификации.
Правовые основания:
- ВК РФ ст. 8, 52, 53, 54 авиационный персонал, обязательная аттестация
- ФАП-147 (приказ Минтранса 147 от 12.09.2008) требования к специалистам по ТО ВС
- ФАП-145 (приказ Минтранса 367 от 18.10.2024) организации по ТО, персонал
- ФАП-148 (приказ Минтранса 148 от 23.06.2003) обязанности эксплуатанта по ПЛГ
- EASA Part-66 Aircraft maintenance licence
- EASA Part-145.A.30 Personnel requirements
- EASA Part-CAMO.A.305 Continuing airworthiness management personnel
- ICAO Annex 1 Personnel Licensing
- ICAO Doc 9760 ch.6 Maintenance personnel
"""
import logging
import uuid
from datetime import datetime, timezone, timedelta
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.api.deps import get_db, get_current_user, require_roles
from app.api.helpers import audit
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/personnel-plg", tags=["personnel-plg"])
# ===================================================================
# PYDANTIC MODELS
# ===================================================================
class SpecialistCreate(BaseModel):
"""Создание карточки специалиста ПЛГ."""
full_name: str = Field(..., min_length=2, max_length=200)
personnel_number: str = Field(..., min_length=1, max_length=50, description="Табельный номер")
position: str = Field(..., description="Должность")
category: str = Field(..., description="Категория: A, B1, B2, B3, C (по EASA Part-66) / I, II, III (по ФАП-147)")
specializations: List[str] = Field(default=[], description="Типы ВС / специализации")
organization_id: Optional[str] = None
license_number: Optional[str] = Field(None, description="Номер свидетельства авиаспециалиста")
license_issued: Optional[str] = None
license_expires: Optional[str] = None
medical_certificate_expires: Optional[str] = None
notes: Optional[str] = None
class AttestationCreate(BaseModel):
"""Запись об аттестации (первичной или очередной)."""
specialist_id: str
attestation_type: str = Field(..., description="initial | periodic | extraordinary | type_rating")
program_id: str = Field(..., description="ID программы подготовки")
program_name: str = Field(..., description="Наименование программы")
training_center: Optional[str] = Field(None, description="АУЦ / учебный центр")
date_start: str
date_end: str
hours_theory: float = Field(0, ge=0)
hours_practice: float = Field(0, ge=0)
exam_score: Optional[float] = Field(None, ge=0, le=100)
result: str = Field(..., description="passed | failed | conditional")
certificate_number: Optional[str] = None
certificate_valid_until: Optional[str] = None
examiner_name: Optional[str] = None
notes: Optional[str] = None
class QualificationUpgrade(BaseModel):
"""Повышение квалификации."""
specialist_id: str
program_id: str
program_name: str
program_type: str = Field(..., description="recurrent | type_extension | crs_authorization | ndt | human_factors | sms | fuel_tank | ewis | rvsm | etops")
training_center: Optional[str] = None
date_start: str
date_end: str
hours_total: float = Field(0, ge=0)
result: str = Field("passed", description="passed | failed | in_progress")
certificate_number: Optional[str] = None
next_due: Optional[str] = None
notes: Optional[str] = None
# ===================================================================
# IN-MEMORY STORAGE (production: PostgreSQL models)
# ===================================================================
_specialists: dict = {}
_attestations: dict = {}
_qualifications: dict = {}
# Pre-built training programs per regulatory framework
TRAINING_PROGRAMS = {
# ============================================
# ПЕРВИЧНАЯ АТТЕСТАЦИЯ (ФАП-147, EASA Part-66)
# ============================================
"PLG-INIT-001": {
"id": "PLG-INIT-001",
"name": "Первичная подготовка специалиста по ПЛГ",
"type": "initial",
"legal_basis": "ФАП-147 п.5, п.17; ВК РФ ст. 53, 54; EASA Part-66.A.25",
"category": "B1/B2",
"duration_hours": 240,
"modules": [
{"code": "M1", "name": "Математика", "hours": 16, "basis": "EASA Part-66 Mod.1"},
{"code": "M2", "name": "Физика", "hours": 16, "basis": "EASA Part-66 Mod.2"},
{"code": "M3", "name": "Основы электротехники", "hours": 20, "basis": "EASA Part-66 Mod.3"},
{"code": "M4", "name": "Основы электроники", "hours": 16, "basis": "EASA Part-66 Mod.4"},
{"code": "M5", "name": "Цифровые методы / ЭВМ", "hours": 16, "basis": "EASA Part-66 Mod.5"},
{"code": "M6", "name": "Материалы и комплектующие", "hours": 20, "basis": "EASA Part-66 Mod.6"},
{"code": "M7", "name": "Практика технического обслуживания", "hours": 40, "basis": "EASA Part-66 Mod.7; ФАП-147 п.17.4"},
{"code": "M8", "name": "Основы аэродинамики", "hours": 12, "basis": "EASA Part-66 Mod.8"},
{"code": "M9", "name": "Человеческий фактор", "hours": 16, "basis": "EASA Part-66 Mod.9; ICAO Doc 9859 ch.2"},
{"code": "M10", "name": "Авиационное законодательство", "hours": 24, "basis": "EASA Part-66 Mod.10; ВК РФ; ФАП-145; ФАП-148"},
{"code": "M11A", "name": "Аэродинамика самолёта, конструкции и системы", "hours": 32, "basis": "EASA Part-66 Mod.11A"},
{"code": "M12", "name": "Авиационные двигатели (вертолёты/самолёты)", "hours": 24, "basis": "EASA Part-66 Mod.12/15"},
{"code": "P1", "name": "Практика на ВС (стажировка)", "hours": 0, "basis": "ФАП-147 п.17.6; EASA Part-66.A.30(a)", "note": "Не менее 6 месяцев"},
],
"exam": {"theory_pass": 75, "practice_pass": "Демонстрация компетенций"},
"certificate_validity_years": 0,
"note": "Свидетельство выдаётся бессрочно при условии прохождения периодических курсов",
},
# ============================================
# ПЕРИОДИЧЕСКОЕ ПОВЫШЕНИЕ КВАЛИФИКАЦИИ
# ============================================
"PLG-REC-001": {
"id": "PLG-REC-001",
"name": "Периодическое повышение квалификации специалиста ПЛГ (recurrent)",
"type": "recurrent",
"legal_basis": "ФАП-147 п.17.8; ФАП-145 п.145.A.35; EASA Part-145.A.35(d); EASA Part-66.A.40",
"periodicity": "Каждые 24 месяца",
"duration_hours": 40,
"modules": [
{"code": "R1", "name": "Изменения в авиационном законодательстве", "hours": 8, "basis": "ФАП-145 п.145.A.35; EASA Part-66 Mod.10"},
{"code": "R2", "name": "Человеческий фактор (refresher)", "hours": 8, "basis": "EASA Part-145.A.30(e); ICAO Doc 9859"},
{"code": "R3", "name": "Новые методы ТО и диагностики", "hours": 8, "basis": "EASA Part-145.A.35(d)"},
{"code": "R4", "name": "Безопасность ТО (SMS)", "hours": 8, "basis": "ICAO Annex 19; ВК РФ ст. 24.1"},
{"code": "R5", "name": "Практические занятия / обзор инцидентов", "hours": 8, "basis": "EASA Part-145.A.35(d); АМРИПП"},
],
"certificate_validity_years": 2,
},
# ============================================
# ДОПУСК НА ТИП ВС (Type Rating)
# ============================================
"PLG-TYPE-001": {
"id": "PLG-TYPE-001",
"name": "Подготовка на тип ВС (Type Rating / квалификационная отметка)",
"type": "type_rating",
"legal_basis": "ФАП-147 п.17.7, п.17.8; EASA Part-66.A.45; EASA Part-145.A.35(c)",
"duration_hours": 80,
"modules": [
{"code": "T1", "name": "Общее описание типа ВС", "hours": 16, "basis": "EASA Part-66 Appendix III"},
{"code": "T2", "name": "Конструкция планера", "hours": 16, "basis": "ATA 51-57"},
{"code": "T3", "name": "Силовая установка", "hours": 16, "basis": "ATA 70-80"},
{"code": "T4", "name": "Системы ВС", "hours": 16, "basis": "ATA 21-49"},
{"code": "T5", "name": "Практика на ВС (OJT)", "hours": 16, "basis": "ФАП-147 п.17.8; EASA Part-66.A.45"},
],
"exam": {"theory_pass": 75, "practice_pass": "Демонстрация на ВС"},
"certificate_validity_years": 0,
"note": "Квалификационная отметка внесена в свидетельство",
},
# ============================================
# СПЕЦИАЛЬНЫЕ КУРСЫ
# ============================================
"PLG-EWIS-001": {
"id": "PLG-EWIS-001",
"name": "EWIS — Электропроводка и соединители",
"type": "ewis",
"legal_basis": "EASA Part-145.A.35(f); FAA AC 25.1701-1; ФАП-148",
"duration_hours": 16,
"modules": [
{"code": "E1", "name": "EWIS awareness", "hours": 8},
{"code": "E2", "name": "EWIS detailed / практика", "hours": 8},
],
"certificate_validity_years": 3,
},
"PLG-FUEL-001": {
"id": "PLG-FUEL-001",
"name": "FTS — Безопасность топливных баков",
"type": "fuel_tank",
"legal_basis": "EASA Part-145.A.35(f); FAA SFAR 88; ФАП-148",
"duration_hours": 8,
"modules": [
{"code": "F1", "name": "Fuel Tank Safety awareness", "hours": 4},
{"code": "F2", "name": "FTS detailed / практика", "hours": 4},
],
"certificate_validity_years": 3,
},
"PLG-NDT-001": {
"id": "PLG-NDT-001",
"name": "Неразрушающий контроль (NDT / НК)",
"type": "ndt",
"legal_basis": "ФАП-147 п.17.8; EASA Part-145.A.30(g); NAS 410 / EN 4179",
"duration_hours": 40,
"modules": [
{"code": "N1", "name": "Визуальный контроль (VT)", "hours": 8},
{"code": "N2", "name": "Магнитопорошковый контроль (MT)", "hours": 8},
{"code": "N3", "name": "Капиллярный контроль (PT)", "hours": 8},
{"code": "N4", "name": "Ультразвуковой контроль (UT)", "hours": 8},
{"code": "N5", "name": "Вихретоковый контроль (ET)", "hours": 8},
],
"certificate_validity_years": 5,
},
"PLG-HF-001": {
"id": "PLG-HF-001",
"name": "Человеческий фактор в ТО (Human Factors / CRM-maintenance)",
"type": "human_factors",
"legal_basis": "EASA Part-145.A.30(e); ICAO Doc 9859 ch.2; ФАП-147 п.17.4",
"duration_hours": 16,
"modules": [
{"code": "HF1", "name": "Dirty Dozen + модель SHELL", "hours": 4},
{"code": "HF2", "name": "Управление ошибками ТО", "hours": 4},
{"code": "HF3", "name": "Коммуникация и работа в команде", "hours": 4},
{"code": "HF4", "name": "Случаи из практики (MEDA)", "hours": 4},
],
"certificate_validity_years": 2,
},
"PLG-SMS-001": {
"id": "PLG-SMS-001",
"name": "Система управления безопасностью полётов (SMS) для персонала ПЛГ",
"type": "sms",
"legal_basis": "ICAO Annex 19; ICAO Doc 9859; ВК РФ ст. 24.1; EASA Part-145.A.65",
"duration_hours": 16,
"modules": [
{"code": "S1", "name": "Основы SMS: 4 компонента", "hours": 4},
{"code": "S2", "name": "Идентификация опасностей и оценка рисков", "hours": 4},
{"code": "S3", "name": "Добровольное сообщение об событиях (VPOR)", "hours": 4},
{"code": "S4", "name": "Культура безопасности / Just Culture", "hours": 4},
],
"certificate_validity_years": 2,
},
"PLG-CRS-001": {
"id": "PLG-CRS-001",
"name": "Допуск к подписанию CRS (Certificate of Release to Service)",
"type": "crs_authorization",
"legal_basis": "ФАП-145 п.145.A.35; EASA Part-145.A.35(a)(b); ФАП-147 п.17.8",
"duration_hours": 24,
"modules": [
{"code": "C1", "name": "Ответственность подписанта CRS", "hours": 8},
{"code": "C2", "name": "Документирование ТО", "hours": 8},
{"code": "C3", "name": "Лётная годность после ТО — проверка", "hours": 8},
],
"certificate_validity_years": 0,
"note": "Допуск внутренний — утверждается приказом руководителя",
},
"PLG-RVSM-001": {
"id": "PLG-RVSM-001",
"name": "RVSM — Обслуживание оборудования RVSM",
"type": "rvsm",
"legal_basis": "ICAO Doc 9574; EASA AMC 145.A.30; ФАП-128",
"duration_hours": 8,
"certificate_validity_years": 3,
},
"PLG-ETOPS-001": {
"id": "PLG-ETOPS-001",
"name": "ETOPS — ТО для полётов увеличенной дальности",
"type": "etops",
"legal_basis": "EASA AMC 20-6; ICAO Annex 6 Part I; ФАП-128",
"duration_hours": 8,
"certificate_validity_years": 3,
},
}
# ===================================================================
# ENDPOINTS
# ===================================================================
@router.get("/programs", tags=["personnel-plg"])
def list_training_programs():
"""Каталог программ подготовки специалистов ПЛГ."""
return {
"total": len(TRAINING_PROGRAMS),
"programs": list(TRAINING_PROGRAMS.values()),
"legal_basis": [
"ФАП-147 (приказ Минтранса №147 от 12.09.2008)",
"ФАП-145 (приказ Минтранса №367 от 18.10.2024)",
"ФАП-148 (приказ Минтранса №148 от 23.06.2003)",
"EASA Part-66 — Aircraft Maintenance Licence",
"EASA Part-145.A.30, A.35 — Personnel requirements",
"EASA Part-CAMO.A.305 — Airworthiness management personnel",
"ICAO Annex 1 — Personnel Licensing",
"ICAO Doc 9760 ch.6 — Maintenance personnel",
"ВК РФ ст. 52, 53, 54 — авиационный персонал",
],
}
@router.get("/programs/{program_id}", tags=["personnel-plg"])
def get_program_detail(program_id: str):
"""Детали программы подготовки с модулями и часами."""
prog = TRAINING_PROGRAMS.get(program_id)
if not prog:
raise HTTPException(status_code=404, detail="Program not found")
return prog
@router.get("/specialists", tags=["personnel-plg"])
def list_specialists(
db: Session = Depends(get_db),
user=Depends(get_current_user),
category: Optional[str] = None,
organization_id: Optional[str] = None,
page: int = Query(1, ge=1),
per_page: int = Query(50, le=200),
):
"""Реестр специалистов ПЛГ."""
items = list(_specialists.values())
if category:
items = [s for s in items if s.get("category") == category]
if organization_id:
items = [s for s in items if s.get("organization_id") == organization_id]
total = len(items)
start = (page - 1) * per_page
return {
"total": total,
"page": page,
"items": items[start:start + per_page],
}
@router.post("/specialists", tags=["personnel-plg"])
def create_specialist(
data: SpecialistCreate,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Создать карточку специалиста ПЛГ."""
sid = str(uuid.uuid4())
specialist = {
"id": sid,
**data.dict(),
"status": "active",
"created_at": datetime.now(timezone.utc).isoformat(),
"attestations": [],
"qualifications": [],
}
_specialists[sid] = specialist
audit(db, user, "create", "personnel_plg", entity_id=sid, description=f"Создан специалист: {data.full_name}")
db.commit()
return specialist
@router.get("/specialists/{specialist_id}", tags=["personnel-plg"])
def get_specialist(specialist_id: str, user=Depends(get_current_user)):
"""Карточка специалиста с историей аттестаций и квалификаций."""
spec = _specialists.get(specialist_id)
if not spec:
raise HTTPException(status_code=404, detail="Specialist not found")
# Attach attestations and qualifications
spec["attestations"] = [a for a in _attestations.values() if a["specialist_id"] == specialist_id]
spec["qualifications"] = [q for q in _qualifications.values() if q["specialist_id"] == specialist_id]
# Calculate compliance status
now = datetime.now(timezone.utc)
overdue = []
for q in spec["qualifications"]:
if q.get("next_due"):
due = datetime.fromisoformat(q["next_due"])
if due < now:
overdue.append(q["program_name"])
spec["compliance"] = {
"status": "non_compliant" if overdue else "compliant",
"overdue_items": overdue,
}
return spec
@router.post("/attestations", tags=["personnel-plg"])
def record_attestation(
data: AttestationCreate,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Записать первичную аттестацию или переаттестацию."""
if data.specialist_id not in _specialists:
raise HTTPException(status_code=404, detail="Specialist not found")
if data.program_id not in TRAINING_PROGRAMS:
raise HTTPException(status_code=400, detail=f"Unknown program: {data.program_id}")
aid = str(uuid.uuid4())
record = {"id": aid, **data.dict(), "created_at": datetime.now(timezone.utc).isoformat()}
_attestations[aid] = record
audit(db, user, "attestation", "personnel_plg", entity_id=data.specialist_id,
description=f"Аттестация {data.attestation_type}: {data.program_name}{data.result}")
db.commit()
return record
@router.post("/qualifications", tags=["personnel-plg"])
def record_qualification(
data: QualificationUpgrade,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""Записать повышение квалификации."""
if data.specialist_id not in _specialists:
raise HTTPException(status_code=404, detail="Specialist not found")
qid = str(uuid.uuid4())
record = {"id": qid, **data.dict(), "created_at": datetime.now(timezone.utc).isoformat()}
_qualifications[qid] = record
audit(db, user, "qualification", "personnel_plg", entity_id=data.specialist_id,
description=f"ПК {data.program_type}: {data.program_name}{data.result}")
db.commit()
return record
@router.get("/compliance-report", tags=["personnel-plg"])
def compliance_report(user=Depends(get_current_user)):
"""Отчёт о соответствии: кто просрочил ПК, у кого истекает свидетельство."""
now = datetime.now(timezone.utc)
soon = now + timedelta(days=90)
report = {"total_specialists": len(_specialists), "compliant": 0, "non_compliant": 0, "expiring_soon": [], "overdue": []}
for sid, spec in _specialists.items():
quals = [q for q in _qualifications.values() if q["specialist_id"] == sid]
is_overdue = False
for q in quals:
if q.get("next_due"):
due = datetime.fromisoformat(q["next_due"])
if due < now:
report["overdue"].append({"specialist": spec["full_name"], "program": q["program_name"], "due": q["next_due"]})
is_overdue = True
elif due < soon:
report["expiring_soon"].append({"specialist": spec["full_name"], "program": q["program_name"], "due": q["next_due"]})
if spec.get("license_expires"):
lic_exp = datetime.fromisoformat(spec["license_expires"])
if lic_exp < soon:
report["expiring_soon"].append({"specialist": spec["full_name"], "item": "Свидетельство", "due": spec["license_expires"]})
if is_overdue:
report["non_compliant"] += 1
else:
report["compliant"] += 1
return report
# ===================================================================
# SCHEDULED: проверка истекающих квалификаций → создание рисков
# ===================================================================
def check_expiring_qualifications(db_session=None):
"""
Проверяет квалификации персонала.
Создаёт risk alerts для просроченных и истекающих в <30 дней.
Вызывается из risk_scheduler (каждые 6 часов).
Правовые основания:
- ФАП-147 п.17.8: эксплуатант обязан обеспечить действующую квалификацию
- ФАП-145 п.145.A.30(e): организация обязана иметь квалифицированный персонал
- EASA Part-145.A.30: personnel requirements
"""
now = datetime.now(timezone.utc)
soon = now + timedelta(days=30)
alerts = []
for sid, spec in _specialists.items():
# Check license expiry
if spec.get("license_expires"):
try:
exp = datetime.fromisoformat(spec["license_expires"]).replace(tzinfo=timezone.utc)
if exp < now:
alerts.append({
"type": "personnel_license_expired",
"severity": "critical",
"specialist_id": sid,
"message": f"Свидетельство {spec.get('license_number', '?')} просрочено",
})
elif exp < soon:
alerts.append({
"type": "personnel_license_expiring",
"severity": "high",
"specialist_id": sid,
"message": f"Свидетельство {spec.get('license_number', '?')} истекает {exp.strftime('%d.%m.%Y')}",
})
except (ValueError, TypeError):
pass
# Check qualification expiry
quals = [q for q in _qualifications.values() if q["specialist_id"] == sid]
for q in quals:
if q.get("next_due"):
try:
due = datetime.fromisoformat(q["next_due"]).replace(tzinfo=timezone.utc)
if due < now:
alerts.append({
"type": "qualification_expired",
"severity": "high",
"specialist_id": sid,
"message": f"ПК просрочена: {q['program_name']}",
})
elif due < soon:
alerts.append({
"type": "qualification_expiring",
"severity": "medium",
"specialist_id": sid,
"message": f"ПК истекает: {q['program_name']} — до {due.strftime('%d.%m.%Y')}",
})
except (ValueError, TypeError):
pass
# Check medical certificate
if spec.get("medical_certificate_expires"):
try:
med = datetime.fromisoformat(spec["medical_certificate_expires"]).replace(tzinfo=timezone.utc)
if med < now:
alerts.append({
"type": "medical_expired",
"severity": "critical",
"specialist_id": sid,
"message": "Медицинское заключение просрочено",
})
except (ValueError, TypeError):
pass
logger.info("Personnel PLG check: %d alerts generated", len(alerts))
return alerts
@router.get("/expiry-alerts", tags=["personnel-plg"])
def get_expiry_alerts(user=Depends(get_current_user)):
"""Alerts о просроченных и истекающих квалификациях / свидетельствах."""
alerts = check_expiring_qualifications()
return {
"total": len(alerts),
"critical": len([a for a in alerts if a["severity"] == "critical"]),
"high": len([a for a in alerts if a["severity"] == "high"]),
"medium": len([a for a in alerts if a["severity"] == "medium"]),
"alerts": alerts,
}
@router.get("/export", tags=["personnel-plg"])
def export_personnel(
format: str = "json",
user=Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Экспорт реестра специалистов ПЛГ (JSON или CSV)."""
from app.api.helpers import audit
from fastapi.responses import StreamingResponse
from io import StringIO
items = list(_specialists.values())
audit(db, user, "export", "personnel_plg",
description=f"Экспорт персонала ПЛГ ({format}, {len(items)} записей)")
db.commit()
if format == "csv":
buf = StringIO()
headers = ["id", "full_name", "personnel_number", "position", "category",
"license_number", "license_expires", "status"]
buf.write(",".join(headers) + "\n")
for s in items:
row = [str(s.get(h, "")) for h in headers]
buf.write(",".join(row) + "\n")
buf.seek(0)
return StreamingResponse(
iter([buf.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=personnel_plg.csv"},
)
return {"total": len(items), "items": items}

View File

@ -0,0 +1,548 @@
"""
Панель регулятора ФАВТ 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)",
}

View File

@ -0,0 +1,398 @@
"""
Наряды на ТО (Work Orders) управление работами по ТО ВС.
Правовые основания:
- ФАП-145 п.145.A.50, A.55, A.60, A.65 порядок выполнения ТО
- ФАП-148 п.3, п.4 программы ТО эксплуатанта
- EASA Part-145.A.50-65 Certification of maintenance
- EASA Part-M.A.801 Aircraft certificate of release to service
- ICAO Annex 6 Part I 8.7 Maintenance release
"""
import uuid, logging
from datetime import datetime, timezone
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_db, get_current_user
from app.api.helpers import audit
import asyncio
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/work-orders", tags=["work-orders"])
_work_orders: dict = {}
class WorkOrderCreate(BaseModel):
wo_number: str = Field(..., description="Номер наряда")
aircraft_reg: str
wo_type: str = Field(..., description="scheduled | unscheduled | ad_compliance | sb_compliance | defect_rectification | modification")
title: str
description: str = ""
ata_chapters: List[str] = Field(default=[])
related_ad_id: Optional[str] = None
related_sb_id: Optional[str] = None
related_defect_id: Optional[str] = None
maintenance_program_ref: Optional[str] = None
priority: str = Field("normal", description="aog | urgent | normal | deferred")
planned_start: Optional[str] = None
planned_end: Optional[str] = None
estimated_manhours: float = 0
assigned_to: Optional[str] = Field(None, description="Специалист / бригада")
parts_required: List[dict] = Field(default=[], description="P/N, qty")
class WorkOrderClose(BaseModel):
actual_manhours: float = 0
findings: str = ""
parts_used: List[dict] = Field(default=[])
crs_signed_by: Optional[str] = Field(None, description="CRS подписант (ФАП-145 п.A.50)")
crs_date: Optional[str] = None
@router.get("/")
def list_work_orders(
status: Optional[str] = None, aircraft_reg: Optional[str] = None,
wo_type: Optional[str] = None, priority: Optional[str] = None,
page: int = Query(1, ge=1), per_page: int = Query(50, le=200),
user=Depends(get_current_user),
):
items = list(_work_orders.values())
if status: items = [w for w in items if w["status"] == status]
if aircraft_reg: items = [w for w in items if w["aircraft_reg"] == aircraft_reg]
if wo_type: items = [w for w in items if w["wo_type"] == wo_type]
if priority: items = [w for w in items if w["priority"] == priority]
total = len(items)
start = (page - 1) * per_page
return {"total": total, "page": page, "items": items[start:start + per_page],
"legal_basis": "ФАП-145 п.A.50-65; EASA Part-145; ICAO Annex 6 8.7"}
@router.post("/")
def create_work_order(data: WorkOrderCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
wid = str(uuid.uuid4())
wo = {"id": wid, **data.dict(), "status": "draft", "created_at": datetime.now(timezone.utc).isoformat()}
_work_orders[wid] = wo
if data.priority == "aog":
try:
from app.services.ws_manager import notify_wo_aog
asyncio.create_task(notify_wo_aog(data.wo_number, data.aircraft_reg))
except Exception:
pass
audit(db, user, "create", "work_order", entity_id=wid, description=f"WO {data.wo_number}: {data.title}")
db.commit()
return wo
@router.get("/{wo_id}")
def get_work_order(wo_id: str, user=Depends(get_current_user)):
wo = _work_orders.get(wo_id)
if not wo: raise HTTPException(404)
return wo
@router.put("/{wo_id}/open")
def open_work_order(wo_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Открыть наряд → в работу."""
wo = _work_orders.get(wo_id)
if not wo: raise HTTPException(404)
wo["status"] = "in_progress"
wo["opened_at"] = datetime.now(timezone.utc).isoformat()
audit(db, user, "open", "work_order", entity_id=wo_id)
db.commit()
return wo
@router.put("/{wo_id}/close")
def close_work_order(wo_id: str, data: WorkOrderClose, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""
Закрыть наряд + CRS (Certificate of Release to Service).
ФАП-145 п.145.A.50: после ТО оформляется свидетельство о допуске к эксплуатации.
"""
wo = _work_orders.get(wo_id)
if not wo: raise HTTPException(404)
wo["status"] = "closed"
wo["closed_at"] = datetime.now(timezone.utc).isoformat()
wo["actual_manhours"] = data.actual_manhours
wo["findings"] = data.findings
wo["parts_used"] = data.parts_used
wo["crs_signed_by"] = data.crs_signed_by
wo["crs_date"] = data.crs_date or datetime.now(timezone.utc).isoformat()
try:
from app.services.ws_manager import notify_wo_closed
asyncio.create_task(notify_wo_closed(wo["wo_number"], wo["aircraft_reg"], data.crs_signed_by or ""))
except Exception:
pass
audit(db, user, "close", "work_order", entity_id=wo_id,
description=f"WO закрыт, CRS: {data.crs_signed_by}")
db.commit()
return wo
@router.put("/{wo_id}/cancel")
def cancel_work_order(wo_id: str, reason: str = "", db: Session = Depends(get_db), user=Depends(get_current_user)):
wo = _work_orders.get(wo_id)
if not wo: raise HTTPException(404)
wo["status"] = "cancelled"
wo["cancel_reason"] = reason
audit(db, user, "cancel", "work_order", entity_id=wo_id)
db.commit()
return wo
@router.get("/stats/summary")
def work_order_stats(user=Depends(get_current_user)):
"""Статистика нарядов для Dashboard."""
items = list(_work_orders.values())
return {
"total": len(items),
"draft": len([w for w in items if w["status"] == "draft"]),
"in_progress": len([w for w in items if w["status"] == "in_progress"]),
"closed": len([w for w in items if w["status"] == "closed"]),
"cancelled": len([w for w in items if w["status"] == "cancelled"]),
"aog": len([w for w in items if w.get("priority") == "aog"]),
"total_manhours": sum(w.get("actual_manhours", 0) for w in items if w["status"] == "closed"),
}
# ===================================================================
# СКВОЗНАЯ ИНТЕГРАЦИЯ: AD → WO, Defect → WO, LL → WO
# ФАП-145 п.A.50: все работы по ТО оформляются нарядом
# ===================================================================
@router.post("/from-directive/{directive_id}")
def create_wo_from_directive(directive_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Создать наряд на выполнение ДЛГ."""
from app.api.routes.airworthiness_core import _directives
ad = _directives.get(directive_id)
if not ad:
raise HTTPException(404, "Directive not found")
wid = str(uuid.uuid4())
wo = {
"id": wid,
"wo_number": f"WO-AD-{ad['number'][:20]}",
"aircraft_reg": ", ".join(ad.get("aircraft_types", [])),
"wo_type": "ad_compliance",
"title": f"Выполнение ДЛГ {ad['number']}: {ad.get('title', '')}",
"description": ad.get("description", ""),
"related_ad_id": directive_id,
"priority": "urgent" if ad.get("compliance_type") == "mandatory" else "normal",
"status": "draft",
"estimated_manhours": 0,
"ata_chapters": [ad["ata_chapter"]] if ad.get("ata_chapter") else [],
"created_at": datetime.now(timezone.utc).isoformat(),
}
_work_orders[wid] = wo
audit(db, user, "create_from_ad", "work_order", entity_id=wid,
description=f"WO из ДЛГ {ad['number']}")
db.commit()
return wo
@router.post("/from-defect/{defect_id}")
def create_wo_from_defect(defect_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Создать наряд на устранение дефекта."""
from app.api.routes.defects import _defects
defect = _defects.get(defect_id)
if not defect:
raise HTTPException(404, "Defect not found")
wid = str(uuid.uuid4())
wo = {
"id": wid,
"wo_number": f"WO-DEF-{defect_id[:8].upper()}",
"aircraft_reg": defect["aircraft_reg"],
"wo_type": "defect_rectification",
"title": f"Устранение дефекта: {defect.get('description', '')[:80]}",
"description": defect.get("description", ""),
"related_defect_id": defect_id,
"priority": "aog" if defect.get("severity") == "critical" else "urgent" if defect.get("severity") == "major" else "normal",
"status": "draft",
"estimated_manhours": 0,
"ata_chapters": [defect["ata_chapter"]] if defect.get("ata_chapter") else [],
"created_at": datetime.now(timezone.utc).isoformat(),
}
_work_orders[wid] = wo
audit(db, user, "create_from_defect", "work_order", entity_id=wid,
description=f"WO из дефекта {defect['aircraft_reg']}")
db.commit()
return wo
@router.post("/from-bulletin/{bulletin_id}")
def create_wo_from_bulletin(bulletin_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Создать наряд на внедрение SB."""
from app.api.routes.airworthiness_core import _bulletins
sb = _bulletins.get(bulletin_id)
if not sb:
raise HTTPException(404, "Bulletin not found")
wid = str(uuid.uuid4())
wo = {
"id": wid,
"wo_number": f"WO-SB-{sb['number'][:20]}",
"aircraft_reg": ", ".join(sb.get("aircraft_types", [])),
"wo_type": "sb_compliance",
"title": f"Внедрение SB {sb['number']}: {sb.get('title', '')}",
"description": sb.get("description", ""),
"related_sb_id": bulletin_id,
"priority": "urgent" if sb.get("category") == "mandatory" else "normal",
"status": "draft",
"estimated_manhours": sb.get("estimated_manhours", 0),
"ata_chapters": [sb["ata_chapter"]] if sb.get("ata_chapter") else [],
"created_at": datetime.now(timezone.utc).isoformat(),
}
_work_orders[wid] = wo
audit(db, user, "create_from_sb", "work_order", entity_id=wid,
description=f"WO из SB {sb['number']}")
db.commit()
return wo
@router.get("/{wo_id}/report/pdf")
def generate_wo_pdf(wo_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""
Генерация PDF отчёта по наряду на ТО (включая CRS).
ФАП-145 п.145.A.55: документация о выполненном ТО.
"""
from io import BytesIO
from datetime import datetime as dt
from fastapi.responses import StreamingResponse
wo = _work_orders.get(wo_id)
if not wo:
raise HTTPException(404, "Work Order not found")
try:
from reportlab.lib.pagesizes import A4
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib import colors
buf = BytesIO()
doc = SimpleDocTemplate(buf, pagesize=A4, topMargin=30, bottomMargin=30)
styles = getSampleStyleSheet()
elements = []
# Title
elements.append(Paragraph(f"НАРЯД НА ТО / WORK ORDER", styles["Title"]))
elements.append(Paragraph(f"No: {wo.get('wo_number', '?')}", styles["Heading2"]))
elements.append(Spacer(1, 12))
# Main info table
data = [
["Борт / Aircraft:", wo.get("aircraft_reg", "")],
["Тип работ / Type:", wo.get("wo_type", "")],
["Наименование / Title:", wo.get("title", "")],
["Приоритет / Priority:", wo.get("priority", "")],
["Статус / Status:", wo.get("status", "")],
["План. ч/ч / Est. MH:", str(wo.get("estimated_manhours", 0))],
["Факт. ч/ч / Actual MH:", str(wo.get("actual_manhours", ""))],
]
t = Table(data, colWidths=[180, 340])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (0, -1), colors.Color(0.9, 0.9, 0.9)),
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
("FONTSIZE", (0, 0), (-1, -1), 9),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]))
elements.append(t)
elements.append(Spacer(1, 16))
# Description
if wo.get("description"):
elements.append(Paragraph("Описание работ:", styles["Heading4"]))
elements.append(Paragraph(wo["description"], styles["Normal"]))
elements.append(Spacer(1, 12))
# Findings
if wo.get("findings"):
elements.append(Paragraph("Замечания / Findings:", styles["Heading4"]))
elements.append(Paragraph(wo["findings"], styles["Normal"]))
elements.append(Spacer(1, 12))
# CRS block
if wo.get("crs_signed_by"):
elements.append(Spacer(1, 20))
elements.append(Paragraph("CERTIFICATE OF RELEASE TO SERVICE (CRS)", styles["Heading3"]))
elements.append(Paragraph(
f"Certifies that the work specified was carried out in accordance with "
f"Part-145 and the aircraft/component is considered ready for release to service.",
styles["Normal"]
))
elements.append(Spacer(1, 8))
crs_data = [
["CRS подписал / Signed by:", wo["crs_signed_by"]],
["Дата / Date:", wo.get("crs_date", "")],
["Основание / Ref:", "ФАП-145 п.145.A.50; EASA Part-145.A.50"],
]
ct = Table(crs_data, colWidths=[180, 340])
ct.setStyle(TableStyle([
("BACKGROUND", (0, 0), (0, -1), colors.Color(0.85, 0.95, 0.85)),
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
("FONTSIZE", (0, 0), (-1, -1), 9),
]))
elements.append(ct)
# Footer
elements.append(Spacer(1, 30))
elements.append(Paragraph(
f"Сформировано: {dt.now().strftime('%d.%m.%Y %H:%M')} | АСУ ТК КЛГ",
styles["Normal"]
))
doc.build(elements)
buf.seek(0)
audit(db, user, "pdf_export", "work_order", entity_id=wo_id,
description=f"PDF WO {wo.get('wo_number', '?')}")
db.commit()
return StreamingResponse(
buf,
media_type="application/pdf",
headers={"Content-Disposition": f"attachment; filename=WO_{wo.get('wo_number', 'report')}.pdf"},
)
except ImportError:
raise HTTPException(500, "ReportLab not installed")
@router.post("/batch-from-program/{program_id}")
def batch_create_from_program(
program_id: str,
aircraft_reg: str = "",
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""
Массовое создание нарядов из программы ТО.
Для каждой задачи в программе создаётся отдельный WO.
ФАП-148 п.3: программа ТО наряды на выполнение.
"""
from app.api.routes.airworthiness_core import _maint_programs
mp = _maint_programs.get(program_id)
if not mp:
raise HTTPException(404, "Maintenance Program not found")
tasks = mp.get("tasks", [])
if not tasks:
raise HTTPException(400, "Program has no tasks")
created = []
for task in tasks:
wid = str(uuid.uuid4())
wo = {
"id": wid,
"wo_number": f"WO-MP-{task.get('task_id', wid[:6])}",
"aircraft_reg": aircraft_reg or mp.get("aircraft_type", ""),
"wo_type": "scheduled",
"title": task.get("description", task.get("task_id", "Task")),
"description": f"Из программы ТО: {mp['name']} ({mp['revision']})",
"maintenance_program_ref": f"{program_id}:{task.get('task_id', '')}",
"priority": "normal",
"status": "draft",
"estimated_manhours": task.get("manhours", 0),
"ata_chapters": [],
"created_at": datetime.now(timezone.utc).isoformat(),
}
_work_orders[wid] = wo
created.append(wo)
audit(db, user, "batch_create", "work_order", entity_id=program_id,
description=f"Batch WO из MP {mp['name']}: {len(created)} нарядов")
db.commit()
return {"program": mp["name"], "created_count": len(created), "work_orders": created}

View File

@ -0,0 +1,23 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Float, Boolean, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base import Base
class Aircraft(Base):
__tablename__ = "aircraft"
id = Column(String(36), primary_key=True, default=lambda: str(__import__("uuid").uuid4()))
registration_number = Column(String(20), unique=True, nullable=False, index=True)
serial_number = Column(String(100))
aircraft_type_id = Column(String(36), ForeignKey("aircraft_types.id"), nullable=True)
operator_id = Column(String(50), ForeignKey("organizations.id"))
year_of_manufacture = Column(Integer)
max_takeoff_weight = Column(Float)
status = Column(String(50), default="active")
is_active = Column(Boolean, default=True)
notes = Column(Text)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
aircraft_type = relationship("AircraftType", backref="aircraft", lazy="joined")
operator = relationship("Organization", backref="aircraft", lazy="joined")

View File

@ -0,0 +1,19 @@
from datetime import datetime, date
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.sql import func
from app.db.base import Base
class AircraftType(Base):
__tablename__ = "aircraft_types"
id = Column(String(36), primary_key=True, default=lambda: str(__import__("uuid").uuid4()))
manufacturer = Column(String(200), nullable=False)
model = Column(String(200), nullable=False)
icao_code = Column(String(10))
engine_type = Column(String(50))
engine_count = Column(Integer, default=2)
max_passengers = Column(Integer)
max_range_km = Column(Integer)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())

View File

@ -0,0 +1,124 @@
from datetime import datetime, date
"""
ORM модели: Контроль ЛГ AD, SB, Life Limits, Maintenance Programs, Components.
Соответствует миграции 006_airworthiness_core.sql.
ВК РФ ст. 36-37.2; ФАП-148; EASA Part-M; ICAO Annex 6/8.
"""
from sqlalchemy import String, ForeignKey, Integer, DateTime, Text, Numeric, Boolean, Date
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.common import TimestampMixin, uuid4_str
class ADDirective(Base, TimestampMixin):
"""Директива лётной годности (ВК РФ ст. 37; ФАП-148 п.4.3; EASA Part-M.A.301)."""
__tablename__ = "ad_directives"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
number: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
title: Mapped[str] = mapped_column(String(500), nullable=False)
issuing_authority: Mapped[str] = mapped_column(String(50), default="FATA")
aircraft_types: Mapped[dict | None] = mapped_column(JSONB, default=list)
ata_chapter: Mapped[str | None] = mapped_column(String(10), nullable=True)
effective_date: Mapped[datetime] = mapped_column(Date, nullable=False)
compliance_type: Mapped[str] = mapped_column(String(20), default="mandatory")
compliance_deadline: Mapped[datetime | None] = mapped_column(Date, nullable=True)
repetitive: Mapped[bool] = mapped_column(Boolean, default=False)
repetitive_interval_hours: Mapped[float | None] = mapped_column(Numeric(8, 1), nullable=True)
repetitive_interval_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
affected_parts: Mapped[dict | None] = mapped_column(JSONB, default=list)
supersedes: Mapped[str | None] = mapped_column(String(100), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="open", index=True)
compliance_date: Mapped[datetime | None] = mapped_column(Date, nullable=True)
compliance_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
class ServiceBulletin(Base, TimestampMixin):
"""Сервисный бюллетень (ФАП-148 п.4.5; EASA Part-21.A.3B)."""
__tablename__ = "service_bulletins"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
number: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
title: Mapped[str] = mapped_column(String(500), nullable=False)
manufacturer: Mapped[str] = mapped_column(String(200), nullable=False)
aircraft_types: Mapped[dict | None] = mapped_column(JSONB, default=list)
ata_chapter: Mapped[str | None] = mapped_column(String(10), nullable=True)
category: Mapped[str] = mapped_column(String(20), default="recommended")
issued_date: Mapped[datetime] = mapped_column(Date, nullable=False)
compliance_deadline: Mapped[datetime | None] = mapped_column(Date, nullable=True)
estimated_manhours: Mapped[float | None] = mapped_column(Numeric(6, 1), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
related_ad_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("ad_directives.id"), nullable=True)
status: Mapped[str] = mapped_column(String(20), default="open", index=True)
incorporation_date: Mapped[datetime | None] = mapped_column(Date, nullable=True)
incorporation_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
related_ad = relationship("ADDirective", foreign_keys=[related_ad_id])
class LifeLimit(Base, TimestampMixin):
"""Ресурсы и сроки службы (ФАП-148 п.4.2; EASA Part-M.A.302)."""
__tablename__ = "life_limits"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
aircraft_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("aircraft.id"), nullable=True, index=True)
component_name: Mapped[str] = mapped_column(String(200), nullable=False)
part_number: Mapped[str] = mapped_column(String(100), nullable=False)
serial_number: Mapped[str] = mapped_column(String(100), nullable=False)
limit_type: Mapped[str] = mapped_column(String(20), nullable=False)
calendar_limit_months: Mapped[int | None] = mapped_column(Integer, nullable=True)
flight_hours_limit: Mapped[float | None] = mapped_column(Numeric(10, 1), nullable=True)
cycles_limit: Mapped[int | None] = mapped_column(Integer, nullable=True)
current_hours: Mapped[float] = mapped_column(Numeric(10, 1), default=0)
current_cycles: Mapped[int] = mapped_column(Integer, default=0)
install_date: Mapped[datetime | None] = mapped_column(Date, nullable=True)
last_overhaul_date: Mapped[datetime | None] = mapped_column(Date, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
aircraft = relationship("Aircraft", foreign_keys=[aircraft_id])
class MaintenanceProgram(Base, TimestampMixin):
"""Программа ТО (ФАП-148 п.3; ICAO Annex 6 Part I 8.3)."""
__tablename__ = "maintenance_programs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
name: Mapped[str] = mapped_column(String(300), nullable=False)
aircraft_type: Mapped[str] = mapped_column(String(100), nullable=False)
revision: Mapped[str] = mapped_column(String(20), default="Rev.0")
approved_by: Mapped[str | None] = mapped_column(String(200), nullable=True)
approval_date: Mapped[datetime | None] = mapped_column(Date, nullable=True)
tasks: Mapped[dict | None] = mapped_column(JSONB, default=list)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
class AircraftComponent(Base, TimestampMixin):
"""Карточка компонента (ФАП-145 п.A.42; EASA Part-M.A.501)."""
__tablename__ = "aircraft_components"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
aircraft_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("aircraft.id"), nullable=True, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
part_number: Mapped[str] = mapped_column(String(100), nullable=False)
serial_number: Mapped[str] = mapped_column(String(100), nullable=False)
ata_chapter: Mapped[str | None] = mapped_column(String(10), nullable=True)
manufacturer: Mapped[str | None] = mapped_column(String(200), nullable=True)
install_date: Mapped[datetime | None] = mapped_column(Date, nullable=True)
install_position: Mapped[str | None] = mapped_column(String(200), nullable=True)
current_hours: Mapped[float] = mapped_column(Numeric(10, 1), default=0)
current_cycles: Mapped[int] = mapped_column(Integer, default=0)
condition: Mapped[str] = mapped_column(String(20), default="serviceable", index=True)
certificate_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
certificate_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
last_shop_visit: Mapped[datetime | None] = mapped_column(Date, nullable=True)
next_overhaul_due: Mapped[datetime | None] = mapped_column(Date, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
aircraft = relationship("Aircraft", foreign_keys=[aircraft_id])

View File

@ -0,0 +1,42 @@
"""
Audit log for multi-tenant tracking: who changed what, when.
Part-M-RU M.A.305-306 compliance: all changes to airworthiness data must be logged.
"""
from datetime import datetime, timezone
from sqlalchemy import String, DateTime, Text, JSON
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
from app.models.common import uuid4_str
class AuditLog(Base):
"""Immutable audit trail entry."""
__tablename__ = "audit_log"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
# Who
user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
user_email: Mapped[str | None] = mapped_column(String(255), nullable=True)
user_role: Mapped[str | None] = mapped_column(String(64), nullable=True)
organization_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
# What
action: Mapped[str] = mapped_column(String(32), nullable=False, index=True, doc="create|update|delete|read|login|export")
entity_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True, doc="Table name / entity type")
entity_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
# Details
changes: Mapped[dict | None] = mapped_column(JSON, nullable=True, doc="JSON diff: {field: {old, new}}")
description: Mapped[str | None] = mapped_column(Text, nullable=True)
ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
# When
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
nullable=False,
index=True,
)

View File

@ -0,0 +1,87 @@
from datetime import datetime, date
"""
ORM модели: Персонал ПЛГ специалисты, аттестации, квалификации.
Соответствует миграции 005_personnel_plg.sql.
ВК РФ ст. 52-54; ФАП-147; EASA Part-66.
"""
from sqlalchemy import String, ForeignKey, Integer, DateTime, Text, Numeric, Boolean, Date
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.common import TimestampMixin, uuid4_str
class PLGSpecialist(Base, TimestampMixin):
"""Специалист по ПЛГ (ФАП-147; EASA Part-66)."""
__tablename__ = "plg_specialists"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
organization_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("organizations.id"), nullable=True, index=True)
full_name: Mapped[str] = mapped_column(String(200), nullable=False)
personnel_number: Mapped[str] = mapped_column(String(50), nullable=False)
position: Mapped[str] = mapped_column(String(200), nullable=False)
category: Mapped[str] = mapped_column(String(10), nullable=False, doc="A/B1/B2/B3/C (Part-66) or I/II/III (ФАП-147)")
specializations: Mapped[dict | None] = mapped_column(JSONB, default=list, doc="Типы ВС")
license_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
license_issued: Mapped[datetime | None] = mapped_column(Date, nullable=True)
license_expires: Mapped[datetime | None] = mapped_column(Date, nullable=True, index=True)
medical_certificate_expires: Mapped[datetime | None] = mapped_column(Date, nullable=True, index=True)
status: Mapped[str] = mapped_column(String(20), default="active")
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
created_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
attestations = relationship("PLGAttestation", back_populates="specialist", cascade="all, delete-orphan")
qualifications = relationship("PLGQualification", back_populates="specialist", cascade="all, delete-orphan")
organization = relationship("Organization", foreign_keys=[organization_id])
class PLGAttestation(Base, TimestampMixin):
"""Аттестация персонала ПЛГ (ФАП-147 п.17; EASA Part-66.A.25/30)."""
__tablename__ = "plg_attestations"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
specialist_id: Mapped[str] = mapped_column(String(36), ForeignKey("plg_specialists.id", ondelete="CASCADE"), nullable=False, index=True)
attestation_type: Mapped[str] = mapped_column(String(30), nullable=False, doc="initial/periodic/extraordinary/type_rating")
program_id: Mapped[str] = mapped_column(String(50), nullable=False)
program_name: Mapped[str] = mapped_column(String(300), nullable=False)
training_center: Mapped[str | None] = mapped_column(String(300), nullable=True)
date_start: Mapped[datetime] = mapped_column(Date, nullable=False)
date_end: Mapped[datetime] = mapped_column(Date, nullable=False)
hours_theory: Mapped[float] = mapped_column(Numeric(6, 1), default=0)
hours_practice: Mapped[float] = mapped_column(Numeric(6, 1), default=0)
exam_score: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)
result: Mapped[str] = mapped_column(String(20), nullable=False, doc="passed/failed/conditional")
certificate_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
certificate_valid_until: Mapped[datetime | None] = mapped_column(Date, nullable=True)
examiner_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
created_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
specialist = relationship("PLGSpecialist", back_populates="attestations")
class PLGQualification(Base, TimestampMixin):
"""Повышение квалификации (ФАП-145 п.A.35; EASA Part-66.A.40)."""
__tablename__ = "plg_qualifications"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
specialist_id: Mapped[str] = mapped_column(String(36), ForeignKey("plg_specialists.id", ondelete="CASCADE"), nullable=False, index=True)
program_id: Mapped[str] = mapped_column(String(50), nullable=False)
program_name: Mapped[str] = mapped_column(String(300), nullable=False)
program_type: Mapped[str] = mapped_column(String(30), nullable=False)
training_center: Mapped[str | None] = mapped_column(String(300), nullable=True)
date_start: Mapped[datetime] = mapped_column(Date, nullable=False)
date_end: Mapped[datetime] = mapped_column(Date, nullable=False)
hours_total: Mapped[float] = mapped_column(Numeric(6, 1), default=0)
result: Mapped[str] = mapped_column(String(20), default="passed")
certificate_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
next_due: Mapped[datetime | None] = mapped_column(Date, nullable=True, index=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
created_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
specialist = relationship("PLGSpecialist", back_populates="qualifications")

View File

@ -0,0 +1,50 @@
from datetime import datetime, date
"""
ORM модели: Наряды на ТО (Work Orders).
ФАП-145 п.A.50-65; EASA Part-145; ICAO Annex 6 Part I 8.7.
"""
from sqlalchemy import String, ForeignKey, Integer, DateTime, Text, Numeric, Boolean
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.common import TimestampMixin, uuid4_str
class WorkOrder(Base, TimestampMixin):
"""Наряд на ТО (ФАП-145 п.A.50-65; EASA Part-145)."""
__tablename__ = "work_orders"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
wo_number: Mapped[str] = mapped_column(String(50), nullable=False)
aircraft_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("aircraft.id"), nullable=True)
aircraft_reg: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
wo_type: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
ata_chapters: Mapped[dict | None] = mapped_column(JSONB, default=list)
related_ad_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("ad_directives.id"), nullable=True)
related_sb_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("service_bulletins.id"), nullable=True)
related_defect_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
maintenance_program_ref: Mapped[str | None] = mapped_column(String(100), nullable=True)
priority: Mapped[str] = mapped_column(String(20), default="normal", index=True)
status: Mapped[str] = mapped_column(String(20), default="draft", index=True)
planned_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
planned_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
estimated_manhours: Mapped[float] = mapped_column(Numeric(8, 1), default=0)
actual_manhours: Mapped[float | None] = mapped_column(Numeric(8, 1), nullable=True)
assigned_to: Mapped[str | None] = mapped_column(String(200), nullable=True)
parts_required: Mapped[dict | None] = mapped_column(JSONB, default=list)
parts_used: Mapped[dict | None] = mapped_column(JSONB, default=list)
findings: Mapped[str | None] = mapped_column(Text, nullable=True)
crs_signed_by: Mapped[str | None] = mapped_column(String(200), nullable=True)
crs_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
opened_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
cancel_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
created_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
aircraft = relationship("Aircraft", foreign_keys=[aircraft_id])
related_ad = relationship("ADDirective", foreign_keys=[related_ad_id])
related_sb = relationship("ServiceBulletin", foreign_keys=[related_sb_id])

View File

@ -0,0 +1,51 @@
"""
Pagination schemas and utilities for multi-user server deployment.
Supports cursor-based and offset-based pagination.
"""
from typing import TypeVar, Generic, List, Optional
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session, Query
T = TypeVar("T")
class PaginationParams(BaseModel):
"""Query params for pagination."""
page: int = Field(default=1, ge=1, description="Page number (1-based)")
per_page: int = Field(default=25, ge=1, le=100, description="Items per page (max 100)")
@property
def offset(self) -> int:
return (self.page - 1) * self.per_page
class PaginatedResponse(BaseModel, Generic[T]):
"""Standard paginated response wrapper."""
items: List[T]
total: int
page: int
per_page: int
pages: int
@classmethod
def from_query(cls, query: Query, params: PaginationParams, schema_cls=None):
total = query.count()
items = query.offset(params.offset).limit(params.per_page).all()
if schema_cls:
items = [schema_cls.model_validate(i) for i in items]
pages = (total + params.per_page - 1) // params.per_page
return cls(
items=items,
total=total,
page=params.page,
per_page=params.per_page,
pages=pages,
)
def paginate(db: Session, query, params: PaginationParams):
"""Apply pagination to a SQLAlchemy query. Returns (items, total)."""
total = query.count()
items = query.offset(params.offset).limit(params.per_page).all()
return items, total

View File

@ -0,0 +1,178 @@
-- КЛГ АСУ ТК: Row-Level Security (RLS) for multi-tenant isolation
-- Part-M-RU compliance: data isolation between organizations
-- Run after initial migration: alembic upgrade head
-- Разработчик: АО «REFLY»
-- 1. Create app setting for current org
DO $$ BEGIN
PERFORM set_config('app.current_org_id', '', true);
EXCEPTION WHEN OTHERS THEN NULL;
END $$;
-- 2. Enable RLS on tenant-scoped tables
-- Organizations: everyone sees their own org; admins see all
ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
CREATE POLICY org_tenant ON organizations
USING (
current_setting('app.current_org_id', true) = '' -- admin/no tenant set
OR id = current_setting('app.current_org_id', true)
);
-- Aircraft: operator sees only their aircraft
ALTER TABLE aircraft ENABLE ROW LEVEL SECURITY;
CREATE POLICY aircraft_tenant ON aircraft
USING (
current_setting('app.current_org_id', true) = ''
OR operator_id = current_setting('app.current_org_id', true)
);
-- Cert Applications: applicant org sees only their applications
ALTER TABLE cert_applications ENABLE ROW LEVEL SECURITY;
CREATE POLICY cert_app_tenant ON cert_applications
USING (
current_setting('app.current_org_id', true) = ''
OR applicant_org_id = current_setting('app.current_org_id', true)
);
-- Users: org members see only their org
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY users_tenant ON users
USING (
current_setting('app.current_org_id', true) = ''
OR organization_id = current_setting('app.current_org_id', true)
);
-- Notifications: user sees only their own
ALTER TABLE notifications ENABLE ROW LEVEL SECURITY;
CREATE POLICY notif_tenant ON notifications
USING (
current_setting('app.current_org_id', true) = ''
OR recipient_user_id IN (
SELECT id FROM users WHERE organization_id = current_setting('app.current_org_id', true)
)
);
-- Airworthiness certificates: via aircraft
ALTER TABLE airworthiness_certificates ENABLE ROW LEVEL SECURITY;
CREATE POLICY aw_cert_tenant ON airworthiness_certificates
USING (
current_setting('app.current_org_id', true) = ''
OR aircraft_id IN (
SELECT id FROM aircraft WHERE operator_id = current_setting('app.current_org_id', true)
)
);
-- Maintenance tasks: via aircraft
ALTER TABLE maintenance_tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY maint_tenant ON maintenance_tasks
USING (
current_setting('app.current_org_id', true) = ''
OR aircraft_id IN (
SELECT id FROM aircraft WHERE operator_id = current_setting('app.current_org_id', true)
)
);
-- Damage reports: via aircraft
ALTER TABLE damage_reports ENABLE ROW LEVEL SECURITY;
CREATE POLICY damage_tenant ON damage_reports
USING (
current_setting('app.current_org_id', true) = ''
OR aircraft_id IN (
SELECT id FROM aircraft WHERE operator_id = current_setting('app.current_org_id', true)
)
);
-- Defect reports: via aircraft
ALTER TABLE defect_reports ENABLE ROW LEVEL SECURITY;
CREATE POLICY defect_tenant ON defect_reports
USING (
current_setting('app.current_org_id', true) = ''
OR aircraft_id IN (
SELECT id FROM aircraft WHERE operator_id = current_setting('app.current_org_id', true)
)
);
-- Modifications: via aircraft
ALTER TABLE aircraft_modifications ENABLE ROW LEVEL SECURITY;
CREATE POLICY mod_tenant ON aircraft_modifications
USING (
current_setting('app.current_org_id', true) = ''
OR aircraft_id IN (
SELECT id FROM aircraft WHERE operator_id = current_setting('app.current_org_id', true)
)
);
-- Risk alerts: via aircraft
ALTER TABLE risk_alerts ENABLE ROW LEVEL SECURITY;
CREATE POLICY risk_tenant ON risk_alerts
USING (
current_setting('app.current_org_id', true) = ''
OR aircraft_id IN (
SELECT id FROM aircraft WHERE operator_id = current_setting('app.current_org_id', true)
)
);
-- LLP components: via aircraft
ALTER TABLE limited_life_components ENABLE ROW LEVEL SECURITY;
CREATE POLICY llp_tenant ON limited_life_components
USING (
current_setting('app.current_org_id', true) = ''
OR aircraft_id IN (
SELECT id FROM aircraft WHERE operator_id = current_setting('app.current_org_id', true)
)
);
-- Landing gear: via aircraft
ALTER TABLE landing_gear_components ENABLE ROW LEVEL SECURITY;
CREATE POLICY lg_tenant ON landing_gear_components
USING (
current_setting('app.current_org_id', true) = ''
OR aircraft_id IN (
SELECT id FROM aircraft WHERE operator_id = current_setting('app.current_org_id', true)
)
);
-- Aircraft history: via aircraft
ALTER TABLE aircraft_history ENABLE ROW LEVEL SECURITY;
CREATE POLICY history_tenant ON aircraft_history
USING (
current_setting('app.current_org_id', true) = ''
OR aircraft_id IN (
SELECT id FROM aircraft WHERE operator_id = current_setting('app.current_org_id', true)
)
);
-- Audits: via aircraft
ALTER TABLE audits ENABLE ROW LEVEL SECURITY;
CREATE POLICY audit_tenant ON audits
USING (
current_setting('app.current_org_id', true) = ''
OR aircraft_id IN (
SELECT id FROM aircraft WHERE operator_id = current_setting('app.current_org_id', true)
)
);
-- Audit log: org scoped (admins see all)
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY auditlog_tenant ON audit_log
USING (
current_setting('app.current_org_id', true) = ''
OR organization_id = current_setting('app.current_org_id', true)
);
-- 3. Tables WITHOUT RLS (shared reference data):
-- checklist_templates, checklist_items — shared across all orgs
-- legal tables (jurisdictions, legal_documents, etc.) — shared reference
-- aircraft_types — shared reference
-- ingest_job_logs — admin only
-- 4. Grant bypass to the app superuser (for admin operations)
-- ALTER ROLE klg_admin BYPASSRLS; -- uncomment for production
-- 5. Performance indexes for RLS subqueries
CREATE INDEX IF NOT EXISTS idx_aircraft_operator_id ON aircraft(operator_id);
CREATE INDEX IF NOT EXISTS idx_cert_applications_org ON cert_applications(applicant_org_id);
CREATE INDEX IF NOT EXISTS idx_users_org ON users(organization_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_org ON audit_log(organization_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(recipient_user_id);

View File

@ -0,0 +1,90 @@
-- Migration: Personnel PLG — сертификация персонала ПЛГ
-- Правовые основания: ВК РФ ст. 52-54; ФАП-147; ФАП-145; EASA Part-66
CREATE TABLE IF NOT EXISTS plg_specialists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id),
full_name VARCHAR(200) NOT NULL,
personnel_number VARCHAR(50) NOT NULL,
position VARCHAR(200) NOT NULL,
category VARCHAR(10) NOT NULL, -- A, B1, B2, B3, C (EASA Part-66) / I, II, III (ФАП-147)
specializations JSONB DEFAULT '[]', -- типы ВС
license_number VARCHAR(100),
license_issued DATE,
license_expires DATE,
medical_certificate_expires DATE,
status VARCHAR(20) DEFAULT 'active', -- active, suspended, expired, revoked
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
created_by UUID,
tenant_id UUID,
UNIQUE(tenant_id, personnel_number)
);
CREATE TABLE IF NOT EXISTS plg_attestations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
specialist_id UUID NOT NULL REFERENCES plg_specialists(id) ON DELETE CASCADE,
attestation_type VARCHAR(30) NOT NULL, -- initial, periodic, extraordinary, type_rating
program_id VARCHAR(50) NOT NULL,
program_name VARCHAR(300) NOT NULL,
training_center VARCHAR(300),
date_start DATE NOT NULL,
date_end DATE NOT NULL,
hours_theory NUMERIC(6,1) DEFAULT 0,
hours_practice NUMERIC(6,1) DEFAULT 0,
exam_score NUMERIC(5,2),
result VARCHAR(20) NOT NULL, -- passed, failed, conditional
certificate_number VARCHAR(100),
certificate_valid_until DATE,
examiner_name VARCHAR(200),
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
created_by UUID,
tenant_id UUID
);
CREATE TABLE IF NOT EXISTS plg_qualifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
specialist_id UUID NOT NULL REFERENCES plg_specialists(id) ON DELETE CASCADE,
program_id VARCHAR(50) NOT NULL,
program_name VARCHAR(300) NOT NULL,
program_type VARCHAR(30) NOT NULL, -- recurrent, type_extension, crs_authorization, ndt, human_factors, sms, fuel_tank, ewis, rvsm, etops
training_center VARCHAR(300),
date_start DATE NOT NULL,
date_end DATE NOT NULL,
hours_total NUMERIC(6,1) DEFAULT 0,
result VARCHAR(20) DEFAULT 'passed',
certificate_number VARCHAR(100),
next_due DATE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
created_by UUID,
tenant_id UUID
);
-- Indexes for compliance reporting
CREATE INDEX IF NOT EXISTS idx_plg_spec_org ON plg_specialists(organization_id);
CREATE INDEX IF NOT EXISTS idx_plg_spec_status ON plg_specialists(status);
CREATE INDEX IF NOT EXISTS idx_plg_spec_tenant ON plg_specialists(tenant_id);
CREATE INDEX IF NOT EXISTS idx_plg_att_spec ON plg_attestations(specialist_id);
CREATE INDEX IF NOT EXISTS idx_plg_qual_spec ON plg_qualifications(specialist_id);
CREATE INDEX IF NOT EXISTS idx_plg_qual_due ON plg_qualifications(next_due);
CREATE INDEX IF NOT EXISTS idx_plg_spec_license_exp ON plg_specialists(license_expires);
CREATE INDEX IF NOT EXISTS idx_plg_spec_med_exp ON plg_specialists(medical_certificate_expires);
-- RLS
ALTER TABLE plg_specialists ENABLE ROW LEVEL SECURITY;
ALTER TABLE plg_attestations ENABLE ROW LEVEL SECURITY;
ALTER TABLE plg_qualifications ENABLE ROW LEVEL SECURITY;
CREATE POLICY IF NOT EXISTS plg_spec_tenant ON plg_specialists
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
CREATE POLICY IF NOT EXISTS plg_att_tenant ON plg_attestations
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
CREATE POLICY IF NOT EXISTS plg_qual_tenant ON plg_qualifications
USING (tenant_id = current_setting('app.tenant_id', true)::uuid);
COMMENT ON TABLE plg_specialists IS 'Реестр специалистов ПЛГ (ВК РФ ст. 52-54; ФАП-147; EASA Part-66)';
COMMENT ON TABLE plg_attestations IS 'Аттестации персонала ПЛГ (ФАП-147 п.17; EASA Part-66.A.25/30)';
COMMENT ON TABLE plg_qualifications IS 'Повышение квалификации (ФАП-145 п.A.35; EASA Part-66.A.40)';

View File

@ -0,0 +1,120 @@
-- Migration: Airworthiness Core — AD, SB, Life Limits, Maintenance Programs, Components
-- ВК РФ ст. 36, 37, 37.2; ФАП-148; ФАП-145; EASA Part-M; ICAO Annex 6/8
CREATE TABLE IF NOT EXISTS ad_directives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
number VARCHAR(100) NOT NULL UNIQUE,
title VARCHAR(500) NOT NULL,
issuing_authority VARCHAR(50) DEFAULT 'FATA',
aircraft_types JSONB DEFAULT '[]',
ata_chapter VARCHAR(10),
effective_date DATE NOT NULL,
compliance_type VARCHAR(20) DEFAULT 'mandatory',
compliance_deadline DATE,
repetitive BOOLEAN DEFAULT false,
repetitive_interval_hours NUMERIC(8,1),
repetitive_interval_days INTEGER,
description TEXT,
affected_parts JSONB DEFAULT '[]',
supersedes VARCHAR(100),
status VARCHAR(20) DEFAULT 'open',
compliance_date DATE,
compliance_notes TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
tenant_id UUID
);
CREATE TABLE IF NOT EXISTS service_bulletins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
number VARCHAR(100) NOT NULL UNIQUE,
title VARCHAR(500) NOT NULL,
manufacturer VARCHAR(200) NOT NULL,
aircraft_types JSONB DEFAULT '[]',
ata_chapter VARCHAR(10),
category VARCHAR(20) DEFAULT 'recommended',
issued_date DATE NOT NULL,
compliance_deadline DATE,
estimated_manhours NUMERIC(6,1),
description TEXT,
related_ad UUID REFERENCES ad_directives(id),
status VARCHAR(20) DEFAULT 'open',
incorporation_date DATE,
incorporation_notes TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
tenant_id UUID
);
CREATE TABLE IF NOT EXISTS life_limits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aircraft_id UUID REFERENCES aircraft(id),
component_name VARCHAR(200) NOT NULL,
part_number VARCHAR(100) NOT NULL,
serial_number VARCHAR(100) NOT NULL,
limit_type VARCHAR(20) NOT NULL,
calendar_limit_months INTEGER,
flight_hours_limit NUMERIC(10,1),
cycles_limit INTEGER,
current_hours NUMERIC(10,1) DEFAULT 0,
current_cycles INTEGER DEFAULT 0,
install_date DATE,
last_overhaul_date DATE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
tenant_id UUID
);
CREATE TABLE IF NOT EXISTS maintenance_programs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(300) NOT NULL,
aircraft_type VARCHAR(100) NOT NULL,
revision VARCHAR(20) DEFAULT 'Rev.0',
approved_by VARCHAR(200),
approval_date DATE,
tasks JSONB DEFAULT '[]',
created_at TIMESTAMPTZ DEFAULT now(),
tenant_id UUID
);
CREATE TABLE IF NOT EXISTS aircraft_components (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aircraft_id UUID REFERENCES aircraft(id),
name VARCHAR(200) NOT NULL,
part_number VARCHAR(100) NOT NULL,
serial_number VARCHAR(100) NOT NULL,
ata_chapter VARCHAR(10),
manufacturer VARCHAR(200),
install_date DATE,
install_position VARCHAR(200),
current_hours NUMERIC(10,1) DEFAULT 0,
current_cycles INTEGER DEFAULT 0,
condition VARCHAR(20) DEFAULT 'serviceable',
certificate_type VARCHAR(50),
certificate_number VARCHAR(100),
last_shop_visit DATE,
next_overhaul_due DATE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
tenant_id UUID,
UNIQUE(tenant_id, serial_number)
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_ad_status ON ad_directives(status);
CREATE INDEX IF NOT EXISTS idx_ad_type ON ad_directives USING gin(aircraft_types);
CREATE INDEX IF NOT EXISTS idx_sb_status ON service_bulletins(status);
CREATE INDEX IF NOT EXISTS idx_ll_aircraft ON life_limits(aircraft_id);
CREATE INDEX IF NOT EXISTS idx_comp_aircraft ON aircraft_components(aircraft_id);
CREATE INDEX IF NOT EXISTS idx_comp_condition ON aircraft_components(condition);
-- RLS
ALTER TABLE ad_directives ENABLE ROW LEVEL SECURITY;
ALTER TABLE service_bulletins ENABLE ROW LEVEL SECURITY;
ALTER TABLE life_limits ENABLE ROW LEVEL SECURITY;
ALTER TABLE maintenance_programs ENABLE ROW LEVEL SECURITY;
ALTER TABLE aircraft_components ENABLE ROW LEVEL SECURITY;
COMMENT ON TABLE ad_directives IS 'Директивы ЛГ (ВК РФ ст. 37; ФАП-148 п.4.3; EASA Part-M.A.301)';
COMMENT ON TABLE service_bulletins IS 'Сервисные бюллетени (ФАП-148 п.4.5; EASA Part-21.A.3B)';
COMMENT ON TABLE life_limits IS 'Ресурсы и сроки службы (ФАП-148 п.4.2; EASA Part-M.A.302)';
COMMENT ON TABLE maintenance_programs IS 'Программы ТО (ФАП-148 п.3; ICAO Annex 6 Part I 8.3)';
COMMENT ON TABLE aircraft_components IS 'Карточки компонентов (ФАП-145 п.A.42; EASA Part-M.A.501)';

View File

@ -0,0 +1,74 @@
-- Migration: Defects + Work Orders
-- ФАП-145 п.A.50-65; EASA Part-M.A.403; Part-145
CREATE TABLE IF NOT EXISTS defects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aircraft_id UUID REFERENCES aircraft(id),
aircraft_reg VARCHAR(20) NOT NULL,
ata_chapter VARCHAR(10),
description TEXT NOT NULL,
severity VARCHAR(20) DEFAULT 'minor',
discovered_by VARCHAR(200),
discovered_during VARCHAR(30) DEFAULT 'preflight',
component_pn VARCHAR(100),
component_sn VARCHAR(100),
mel_reference VARCHAR(50),
deferred BOOLEAN DEFAULT false,
deferred_until DATE,
corrective_action TEXT,
status VARCHAR(20) DEFAULT 'open',
rectified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
created_by UUID,
tenant_id UUID
);
CREATE TABLE IF NOT EXISTS work_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
wo_number VARCHAR(50) NOT NULL,
aircraft_id UUID REFERENCES aircraft(id),
aircraft_reg VARCHAR(20) NOT NULL,
wo_type VARCHAR(30) NOT NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
ata_chapters JSONB DEFAULT '[]',
related_ad_id UUID REFERENCES ad_directives(id),
related_sb_id UUID REFERENCES service_bulletins(id),
related_defect_id UUID REFERENCES defects(id),
maintenance_program_ref VARCHAR(100),
priority VARCHAR(20) DEFAULT 'normal',
status VARCHAR(20) DEFAULT 'draft',
planned_start TIMESTAMPTZ,
planned_end TIMESTAMPTZ,
estimated_manhours NUMERIC(8,1) DEFAULT 0,
actual_manhours NUMERIC(8,1),
assigned_to VARCHAR(200),
parts_required JSONB DEFAULT '[]',
parts_used JSONB DEFAULT '[]',
findings TEXT,
crs_signed_by VARCHAR(200),
crs_date TIMESTAMPTZ,
opened_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ,
cancel_reason TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
created_by UUID,
tenant_id UUID,
UNIQUE(tenant_id, wo_number)
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_defects_aircraft ON defects(aircraft_reg);
CREATE INDEX IF NOT EXISTS idx_defects_status ON defects(status);
CREATE INDEX IF NOT EXISTS idx_defects_severity ON defects(severity);
CREATE INDEX IF NOT EXISTS idx_wo_aircraft ON work_orders(aircraft_reg);
CREATE INDEX IF NOT EXISTS idx_wo_status ON work_orders(status);
CREATE INDEX IF NOT EXISTS idx_wo_priority ON work_orders(priority);
CREATE INDEX IF NOT EXISTS idx_wo_type ON work_orders(wo_type);
-- RLS
ALTER TABLE defects ENABLE ROW LEVEL SECURITY;
ALTER TABLE work_orders ENABLE ROW LEVEL SECURITY;
COMMENT ON TABLE defects IS 'Дефекты ВС (ФАП-145 п.A.50; EASA Part-M.A.403)';
COMMENT ON TABLE work_orders IS 'Наряды на ТО (ФАП-145 п.A.50-65; EASA Part-145)';

View File

@ -0,0 +1,132 @@
"""Tests for Airworthiness Core — AD, SB, Life Limits, MP, Components."""
import pytest
from tests.conftest import *
class TestDirectives:
def test_list_empty(self, client, auth_headers):
resp = client.get("/api/v1/airworthiness-core/directives", headers=auth_headers)
assert resp.status_code == 200
assert "legal_basis" in resp.json()
def test_create_directive(self, client, auth_headers):
resp = client.post("/api/v1/airworthiness-core/directives", headers=auth_headers, json={
"number": "AD 2026-02-01R1",
"title": "Inspection of wing spar fitting",
"issuing_authority": "FATA",
"aircraft_types": ["SSJ-100"],
"ata_chapter": "57",
"effective_date": "2026-03-01",
"compliance_type": "mandatory",
"description": "Mandatory inspection per ФАП-148 п.4.3",
})
assert resp.status_code == 200
assert resp.json()["number"] == "AD 2026-02-01R1"
def test_comply_directive(self, client, auth_headers):
d = client.post("/api/v1/airworthiness-core/directives", headers=auth_headers, json={
"number": "AD-TEST-COMPLY",
"title": "Test directive",
"effective_date": "2026-01-01",
}).json()
resp = client.put(f"/api/v1/airworthiness-core/directives/{d['id']}/comply",
headers=auth_headers, params={"notes": "Complied per WO-123"})
assert resp.status_code == 200
assert resp.json()["status"] == "complied"
class TestBulletins:
def test_create_bulletin(self, client, auth_headers):
resp = client.post("/api/v1/airworthiness-core/bulletins", headers=auth_headers, json={
"number": "SB-737-32-1456",
"title": "MLG trunnion inspection",
"manufacturer": "Boeing",
"aircraft_types": ["Boeing 737"],
"category": "mandatory",
"issued_date": "2026-01-15",
"estimated_manhours": 24.5,
})
assert resp.status_code == 200
assert resp.json()["category"] == "mandatory"
class TestLifeLimits:
def test_create_life_limit(self, client, auth_headers):
resp = client.post("/api/v1/airworthiness-core/life-limits", headers=auth_headers, json={
"component_name": "Engine Fan Disk",
"part_number": "1234-5678",
"serial_number": "SN-001",
"limit_type": "combined",
"flight_hours_limit": 20000,
"cycles_limit": 10000,
"current_hours": 15000,
"current_cycles": 7500,
})
assert resp.status_code == 200
def test_list_life_limits_with_remaining(self, client, auth_headers):
client.post("/api/v1/airworthiness-core/life-limits", headers=auth_headers, json={
"component_name": "Test Component",
"part_number": "PN-1",
"serial_number": "SN-REM",
"limit_type": "flight_hours",
"flight_hours_limit": 1000,
"current_hours": 900,
})
resp = client.get("/api/v1/airworthiness-core/life-limits", headers=auth_headers)
items = resp.json()["items"]
found = [i for i in items if i["serial_number"] == "SN-REM"]
assert len(found) > 0
assert "remaining" in found[0]
class TestMaintPrograms:
def test_create_program(self, client, auth_headers):
resp = client.post("/api/v1/airworthiness-core/maintenance-programs", headers=auth_headers, json={
"name": "SSJ-100 Approved Maintenance Program",
"aircraft_type": "SSJ-100",
"revision": "Rev.5",
"approved_by": "ФАВТ",
"tasks": [
{"task_id": "A-01", "description": "Daily check", "interval_days": 1},
{"task_id": "A-48", "description": "48h check", "interval_hours": 48},
{"task_id": "C-01", "description": "C-check", "interval_months": 18},
],
})
assert resp.status_code == 200
assert len(resp.json()["tasks"]) == 3
class TestComponents:
def test_create_component(self, client, auth_headers):
resp = client.post("/api/v1/airworthiness-core/components", headers=auth_headers, json={
"name": "Nose Landing Gear Assembly",
"part_number": "NLG-1234",
"serial_number": "NLG-SN-001",
"ata_chapter": "32",
"manufacturer": "Liebherr",
"condition": "serviceable",
"certificate_type": "EASA Form 1",
})
assert resp.status_code == 200
assert resp.json()["condition"] == "serviceable"
def test_transfer_component(self, client, auth_headers):
c = client.post("/api/v1/airworthiness-core/components", headers=auth_headers, json={
"name": "APU", "part_number": "APU-1", "serial_number": "APU-SN-1",
"condition": "overhauled",
}).json()
resp = client.put(f"/api/v1/airworthiness-core/components/{c['id']}/transfer",
headers=auth_headers, params={"new_aircraft_id": "ac-123", "position": "Tail Section"})
assert resp.status_code == 200
assert resp.json()["install_position"] == "Tail Section"
class TestAircraftStatus:
def test_status_report(self, client, auth_headers):
resp = client.get("/api/v1/airworthiness-core/aircraft-status/RA-12345", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert "summary" in data
assert "airworthy" in data
assert "legal_basis" in data

97
backend/tests/test_api.py Normal file
View File

@ -0,0 +1,97 @@
"""
Core API route tests for КЛГ АСУ ТК.
Tests: health, organizations CRUD, aircraft CRUD, cert_applications workflow.
"""
import pytest
class TestHealth:
def test_health_ok(self, client):
r = client.get("/api/v1/health")
assert r.status_code == 200
data = r.json()
assert data["status"] in ("ok", "degraded")
class TestOrganizations:
def test_list_empty(self, client, auth_headers):
r = client.get("/api/v1/organizations", headers=auth_headers)
assert r.status_code == 200
data = r.json()
assert "items" in data
assert data["total"] == 0
def test_create_and_get(self, client, auth_headers):
payload = {"kind": "operator", "name": "Test Airlines", "inn": "1234567890"}
r = client.post("/api/v1/organizations", json=payload, headers=auth_headers)
assert r.status_code == 201
org = r.json()
assert org["name"] == "Test Airlines"
assert org["kind"] == "operator"
# GET by id
r2 = client.get(f"/api/v1/organizations/{org['id']}", headers=auth_headers)
assert r2.status_code == 200
assert r2.json()["name"] == "Test Airlines"
def test_pagination(self, client, auth_headers):
# Create 3 orgs
for i in range(3):
client.post("/api/v1/organizations",
json={"kind": "operator", "name": f"Org {i}"},
headers=auth_headers)
r = client.get("/api/v1/organizations?per_page=2&page=1", headers=auth_headers)
data = r.json()
assert len(data["items"]) == 2
assert data["total"] == 3
assert data["pages"] == 2
def test_search(self, client, auth_headers):
client.post("/api/v1/organizations", json={"kind": "operator", "name": "Alpha Airlines"}, headers=auth_headers)
client.post("/api/v1/organizations", json={"kind": "mro", "name": "Beta MRO"}, headers=auth_headers)
r = client.get("/api/v1/organizations?q=Alpha", headers=auth_headers)
assert r.json()["total"] == 1
def test_update(self, client, auth_headers):
r = client.post("/api/v1/organizations", json={"kind": "operator", "name": "Old Name"}, headers=auth_headers)
org_id = r.json()["id"]
r2 = client.patch(f"/api/v1/organizations/{org_id}", json={"name": "New Name"}, headers=auth_headers)
assert r2.status_code == 200
assert r2.json()["name"] == "New Name"
def test_delete(self, client, auth_headers):
r = client.post("/api/v1/organizations", json={"kind": "operator", "name": "To Delete"}, headers=auth_headers)
org_id = r.json()["id"]
r2 = client.delete(f"/api/v1/organizations/{org_id}", headers=auth_headers)
assert r2.status_code == 204
class TestAuditEvents:
def test_audit_events_paginated(self, client, auth_headers):
r = client.get("/api/v1/audit/events", headers=auth_headers)
assert r.status_code == 200
data = r.json()
assert "items" in data
def test_audit_created_on_org_create(self, client, auth_headers):
client.post("/api/v1/organizations", json={"kind": "operator", "name": "Audited Org"}, headers=auth_headers)
r = client.get("/api/v1/audit/events?entity_type=organization", headers=auth_headers)
data = r.json()
assert data["total"] >= 1
class TestUsers:
def test_get_me(self, client, auth_headers):
r = client.get("/api/v1/users/me", headers=auth_headers)
assert r.status_code == 200
assert "display_name" in r.json()
class TestNotifications:
def test_list_empty(self, client, auth_headers):
r = client.get("/api/v1/notifications", headers=auth_headers)
assert r.status_code == 200
def test_read_all(self, client, auth_headers):
r = client.post("/api/v1/notifications/read-all", headers=auth_headers)
assert r.status_code == 200

127
backend/tests/test_batch.py Normal file
View File

@ -0,0 +1,127 @@
"""
Tests for batch operations and email service.
"""
import pytest
class TestBatchDelete:
def test_batch_delete_aircraft(self, client, auth_headers):
resp = client.post("/api/v1/batch/delete", headers=auth_headers,
json={"entity_type": "aircraft", "ids": ["fake-id-1", "fake-id-2"]})
assert resp.status_code == 200
data = resp.json()
assert "deleted" in data
def test_batch_delete_unknown_entity(self, client, auth_headers):
resp = client.post("/api/v1/batch/delete", headers=auth_headers,
json={"entity_type": "unknown", "ids": ["id1"]})
assert resp.status_code == 200
data = resp.json()
assert "error" in data
class TestBatchStatusUpdate:
def test_batch_status_update(self, client, auth_headers):
resp = client.post("/api/v1/batch/status", headers=auth_headers,
json={"entity_type": "aircraft", "ids": ["fake-id"], "status": "active"})
assert resp.status_code == 200
data = resp.json()
assert "updated" in data
class TestEmailService:
def test_email_stub_send(self):
from app.services.email_service import EmailService, EmailMessage
svc = EmailService() # No SMTP = stub mode
result = svc.send(EmailMessage(to="test@test.com", subject="Test", body="Hello"))
assert result is True
def test_risk_alert_email(self):
from app.services.email_service import email_service
result = email_service.send_risk_alert("admin@klg.ru", "Просроченный ресурс", "critical", "RA-12345")
assert result is True
def test_application_status_email(self):
from app.services.email_service import email_service
result = email_service.send_application_status("user@klg.ru", "APP-001", "approved")
assert result is True
class TestStatsEndpoint:
def test_get_stats(self, client, auth_headers):
resp = client.get("/api/v1/stats", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, dict)
class TestLegalEndpoints:
def test_list_fap(self, client, auth_headers):
resp = client.get("/api/v1/legal/fap", headers=auth_headers)
assert resp.status_code in [200, 404]
def test_list_icao(self, client, auth_headers):
resp = client.get("/api/v1/legal/icao", headers=auth_headers)
assert resp.status_code in [200, 404]
class TestBackup:
def test_backup_export(self, client, auth_headers):
resp = client.get("/api/v1/backup/export", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert "data" in data
assert "version" in data
assert "total_records" in data
def test_backup_stats(self, client, auth_headers):
resp = client.get("/api/v1/backup/stats", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert "tables" in data
assert "total" in data
def test_openapi_export(self, client):
resp = client.get("/api/v1/health/openapi.json")
assert resp.status_code in [200, 404]
class TestEnhancedHealth:
def test_health_with_deps(self, client):
resp = client.get("/api/v1/health")
assert resp.status_code == 200
data = resp.json()
assert "status" in data
assert "database" in data
assert "version" in data
def test_health_has_environment(self, client):
resp = client.get("/api/v1/health")
data = resp.json()
assert data.get("environment") in ["development", "production"]
class TestRestore:
def test_restore_invalid_file(self, client, auth_headers):
from io import BytesIO
resp = client.post(
"/api/v1/backup/restore",
headers=auth_headers,
files={"file": ("backup.json", BytesIO(b"not json"), "application/json")},
)
assert resp.status_code == 200
data = resp.json()
assert "error" in data
def test_restore_valid_empty_backup(self, client, auth_headers):
import json
from io import BytesIO
backup = json.dumps({"version": "2.2.0", "data": {}, "created_at": "2025-01-01"})
resp = client.post(
"/api/v1/backup/restore",
headers=auth_headers,
files={"file": ("backup.json", BytesIO(backup.encode()), "application/json")},
)
assert resp.status_code == 200
data = resp.json()
assert "restored" in data

View File

@ -0,0 +1,32 @@
"""Tests for Defects."""
import pytest
from tests.conftest import *
class TestDefects:
def test_create_defect(self, client, auth_headers):
resp = client.post("/api/v1/defects/", headers=auth_headers, json={
"aircraft_reg": "RA-89001",
"ata_chapter": "32",
"description": "NLG steering shimmy during taxi",
"severity": "major",
"discovered_during": "preflight",
})
assert resp.status_code == 200
assert resp.json()["severity"] == "major"
def test_rectify_defect(self, client, auth_headers):
d = client.post("/api/v1/defects/", headers=auth_headers, json={
"aircraft_reg": "RA-TEST", "description": "Test", "severity": "minor",
}).json()
resp = client.put(f"/api/v1/defects/{d['id']}/rectify",
headers=auth_headers, params={"action": "Replaced O-ring"})
assert resp.json()["status"] == "rectified"
def test_defer_defect_mel(self, client, auth_headers):
d = client.post("/api/v1/defects/", headers=auth_headers, json={
"aircraft_reg": "RA-TEST2", "description": "Minor IFE fault", "severity": "minor",
}).json()
resp = client.put(f"/api/v1/defects/{d['id']}/defer",
headers=auth_headers, params={"mel_ref": "MEL 25-11-01", "until": "2026-03-15"})
assert resp.json()["status"] == "deferred"
assert resp.json()["mel_reference"] == "MEL 25-11-01"

View File

@ -0,0 +1,132 @@
"""
Tests for export endpoint and additional routes.
"""
import pytest
class TestExport:
"""Export endpoint tests."""
def test_export_aircraft_csv(self, client, auth_headers):
resp = client.get("/api/v1/export/aircraft?format=csv", headers=auth_headers)
assert resp.status_code == 200
assert "text/csv" in resp.headers.get("content-type", "")
def test_export_aircraft_json(self, client, auth_headers):
resp = client.get("/api/v1/export/aircraft?format=json", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
def test_export_organizations_csv(self, client, auth_headers):
resp = client.get("/api/v1/export/organizations?format=csv", headers=auth_headers)
assert resp.status_code == 200
def test_export_unknown_dataset(self, client, auth_headers):
resp = client.get("/api/v1/export/unknown_thing?format=csv", headers=auth_headers)
assert resp.status_code == 400
def test_export_with_limit(self, client, auth_headers):
resp = client.get("/api/v1/export/aircraft?format=json&limit=5", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert len(data) <= 5
class TestNotifications:
"""Notification endpoint tests."""
def test_list_notifications(self, client, auth_headers):
resp = client.get("/api/v1/notifications", headers=auth_headers)
assert resp.status_code in [200, 422]
def test_mark_all_read(self, client, auth_headers):
resp = client.put("/api/v1/notifications/read-all", headers=auth_headers)
assert resp.status_code in [200, 204, 404]
class TestHealthAndMetrics:
"""Health and metrics tests."""
def test_health_check(self, client):
resp = client.get("/api/v1/health")
assert resp.status_code == 200
data = resp.json()
assert "status" in data
def test_metrics_endpoint(self, client):
resp = client.get("/api/v1/metrics")
assert resp.status_code == 200
text = resp.text
assert "klg_http_requests_total" in text or "requests" in text.lower()
class TestAuditLog:
"""Audit log tests."""
def test_list_audit_log(self, client, auth_headers):
resp = client.get("/api/v1/audit-log", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert "items" in data or isinstance(data, list)
def test_filter_audit_log_by_entity(self, client, auth_headers):
resp = client.get("/api/v1/audit-log?entity_type=aircraft", headers=auth_headers)
assert resp.status_code == 200
def test_filter_audit_log_by_action(self, client, auth_headers):
resp = client.get("/api/v1/audit-log?action=create", headers=auth_headers)
assert resp.status_code == 200
class TestWorkflows:
"""Workflow integration tests."""
def test_application_create_and_submit(self, client, auth_headers):
# Create
resp = client.post("/api/v1/cert-applications", headers=auth_headers,
json={"subject": "Test workflow application"})
if resp.status_code == 200:
app = resp.json()
app_id = app.get("id")
# Submit
if app_id:
resp2 = client.post(f"/api/v1/cert-applications/{app_id}/submit", headers=auth_headers)
assert resp2.status_code in [200, 400, 404] # 400 if already submitted
def test_risk_scan_and_resolve(self, client, auth_headers):
# Scan
resp = client.post("/api/v1/risk-alerts/scan", headers=auth_headers)
assert resp.status_code in [200, 201, 404]
def test_checklist_generate_fap_m(self, client, auth_headers):
resp = client.post("/api/v1/checklists/generate", headers=auth_headers,
json={"source": "fap_m_inspection", "name": "Test ФАП-М"})
assert resp.status_code in [200, 201, 404]
class TestEdgeCases:
"""Edge cases and error handling."""
def test_invalid_uuid_returns_error(self, client, auth_headers):
resp = client.get("/api/v1/aircraft/not-a-uuid", headers=auth_headers)
assert resp.status_code in [400, 404, 422]
def test_create_with_missing_fields(self, client, auth_headers):
resp = client.post("/api/v1/organizations", headers=auth_headers, json={})
assert resp.status_code in [400, 422]
def test_unauthorized_without_token(self, client):
resp = client.get("/api/v1/aircraft")
assert resp.status_code in [401, 403]
def test_pagination_params(self, client, auth_headers):
resp = client.get("/api/v1/aircraft?page=1&per_page=5", headers=auth_headers)
if resp.status_code == 200:
data = resp.json()
if "per_page" in data:
assert data["per_page"] <= 5
def test_search_param(self, client, auth_headers):
resp = client.get("/api/v1/aircraft?q=Boeing", headers=auth_headers)
assert resp.status_code == 200

View File

@ -0,0 +1,101 @@
"""
Extended tests: checklists, cert-applications workflow, pagination.
"""
import pytest
class TestChecklistTemplates:
def test_list_templates(self, client, auth_headers):
r = client.get("/api/v1/checklists/templates", headers=auth_headers)
assert r.status_code == 200
data = r.json()
assert "items" in data
assert "total" in data
def test_create_template(self, client, auth_headers):
r = client.post("/api/v1/checklists/templates", json={
"name": "Test Template", "version": 1, "domain": "test",
"items": [
{"code": "T.001", "text": "Check item 1", "sort_order": 1},
{"code": "T.002", "text": "Check item 2", "sort_order": 2},
]
}, headers=auth_headers)
assert r.status_code == 201
data = r.json()
assert data["name"] == "Test Template"
assert len(data["items"]) == 2
def test_generate_fap_m(self, client, auth_headers):
r = client.post(
"/api/v1/checklists/generate?source=fap_m_inspection&name=FAP-M Test",
headers=auth_headers,
)
assert r.status_code == 200
data = r.json()
assert data["domain"] == "fap_m_inspection"
assert len(data["items"]) == 5
def test_get_template_by_id(self, client, auth_headers):
# Create first
r = client.post("/api/v1/checklists/templates", json={
"name": "Lookup Template", "version": 1,
}, headers=auth_headers)
tid = r.json()["id"]
# Get by id
r2 = client.get(f"/api/v1/checklists/templates/{tid}", headers=auth_headers)
assert r2.status_code == 200
assert r2.json()["id"] == tid
class TestPagination:
"""Verify pagination format across endpoints."""
def test_organizations_pagination_format(self, client, auth_headers):
r = client.get("/api/v1/organizations?page=1&per_page=5", headers=auth_headers)
data = r.json()
for key in ("items", "total", "page", "per_page", "pages"):
assert key in data, f"Missing key: {key}"
assert data["page"] == 1
assert data["per_page"] == 5
def test_per_page_cap(self, client, auth_headers):
"""per_page should be capped at 100."""
r = client.get("/api/v1/organizations?per_page=999", headers=auth_headers)
# FastAPI validation should cap or reject
assert r.status_code in (200, 422)
def test_aircraft_pagination(self, client, auth_headers):
r = client.get("/api/v1/aircraft?page=1&per_page=10", headers=auth_headers)
data = r.json()
assert "items" in data
assert "total" in data
class TestRiskAlerts:
def test_list_risks(self, client, auth_headers):
r = client.get("/api/v1/risk-alerts", headers=auth_headers)
assert r.status_code == 200
data = r.json()
assert "items" in data
def test_scan_risks(self, client, auth_headers):
r = client.post("/api/v1/risk-alerts/scan", headers=auth_headers)
assert r.status_code == 200
data = r.json()
assert "created" in data
class TestStats:
def test_get_stats(self, client, auth_headers):
r = client.get("/api/v1/stats", headers=auth_headers)
assert r.status_code == 200
data = r.json()
assert "aircraft" in data
assert "risks" in data
class TestTasksDashboard:
def test_list_tasks(self, client, auth_headers):
r = client.get("/api/v1/tasks", headers=auth_headers)
assert r.status_code == 200
assert isinstance(r.json(), list)

View File

@ -0,0 +1,126 @@
"""Tests for ФГИС РЭВС integration."""
import pytest
from tests.conftest import *
class TestFGISPull:
def test_aircraft_registry(self, client, auth_headers):
resp = client.get("/api/v1/fgis-revs/aircraft-registry", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["source"] == "ФГИС РЭВС"
assert data["total"] >= 1
assert data["items"][0]["registration"].startswith("RA-")
def test_aircraft_by_registration(self, client, auth_headers):
resp = client.get("/api/v1/fgis-revs/aircraft-registry?registration=RA-89001", headers=auth_headers)
assert resp.status_code == 200
assert len(resp.json()["items"]) == 1
def test_certificates(self, client, auth_headers):
resp = client.get("/api/v1/fgis-revs/certificates", headers=auth_headers)
assert resp.status_code == 200
items = resp.json()["items"]
assert any(c["status"] == "valid" for c in items)
def test_operators(self, client, auth_headers):
resp = client.get("/api/v1/fgis-revs/operators", headers=auth_headers)
assert resp.status_code == 200
assert resp.json()["items"][0]["certificate_number"].startswith("ЭВ-")
def test_directives(self, client, auth_headers):
resp = client.get("/api/v1/fgis-revs/directives", headers=auth_headers)
assert resp.status_code == 200
assert all(d["issuing_authority"] == "ФАВТ" for d in resp.json()["items"])
def test_maint_organizations(self, client, auth_headers):
resp = client.get("/api/v1/fgis-revs/maintenance-organizations", headers=auth_headers)
assert resp.status_code == 200
assert resp.json()["items"][0]["approval_scope"]
class TestFGISPush:
def test_push_compliance_report(self, client, auth_headers):
resp = client.post("/api/v1/fgis-revs/push/compliance-report", headers=auth_headers, json={
"directive_number": "АД-2026-0012",
"aircraft_registration": "RA-89001",
"compliance_date": "2026-02-13",
"work_order_number": "WO-AD-001",
"crs_signed_by": "Иванов И.И.",
})
assert resp.status_code == 200
assert resp.json()["action"] == "push_compliance"
def test_push_maintenance_report(self, client, auth_headers):
resp = client.post("/api/v1/fgis-revs/push/maintenance-report", headers=auth_headers, json={
"work_order_number": "WO-TEST-001",
"aircraft_registration": "RA-89001",
"work_type": "scheduled",
"completion_date": "2026-02-13",
"crs_signed_by": "Петров П.П.",
"actual_manhours": 48,
})
assert resp.status_code == 200
def test_push_defect_report(self, client, auth_headers):
resp = client.post("/api/v1/fgis-revs/push/defect-report", headers=auth_headers, json={
"aircraft_registration": "RA-89001",
"defect_description": "Crack found on wing spar",
"severity": "critical",
"ata_chapter": "57",
})
assert resp.status_code == 200
assert resp.json()["legal_basis"] == "ФАП-128"
class TestFGISSync:
def test_sync_aircraft(self, client, auth_headers):
resp = client.post("/api/v1/fgis-revs/sync/aircraft", headers=auth_headers)
assert resp.status_code == 200
result = resp.json()["result"]
assert result["entity_type"] == "aircraft"
assert result["status"] in ("success", "partial")
def test_sync_certificates(self, client, auth_headers):
resp = client.post("/api/v1/fgis-revs/sync/certificates", headers=auth_headers)
assert resp.status_code == 200
def test_sync_directives(self, client, auth_headers):
resp = client.post("/api/v1/fgis-revs/sync/directives", headers=auth_headers)
assert resp.status_code == 200
def test_sync_all(self, client, auth_headers):
resp = client.post("/api/v1/fgis-revs/sync/all", headers=auth_headers)
assert resp.status_code == 200
results = resp.json()["results"]
assert "aircraft" in results
assert "certificates" in results
assert "directives" in results
def test_sync_status(self, client, auth_headers):
# Run a sync first
client.post("/api/v1/fgis-revs/sync/aircraft", headers=auth_headers)
resp = client.get("/api/v1/fgis-revs/sync/status", headers=auth_headers)
assert resp.status_code == 200
assert resp.json()["total_syncs"] >= 1
class TestFGISConnection:
def test_connection_status(self, client, auth_headers):
resp = client.get("/api/v1/fgis-revs/connection-status", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert "fgis_revs" in data
assert "smev_30" in data
assert data["fgis_revs"]["status"] == "mock_mode"
class TestSMEV:
def test_smev_send(self, client, auth_headers):
resp = client.post("/api/v1/fgis-revs/smev/send", headers=auth_headers, json={
"service_code": "FAVT-001",
"data": {"registration": "RA-89001"},
})
assert resp.status_code == 200
assert resp.json()["service_code"] == "FAVT-001"
assert resp.json()["message_id"]

View File

@ -0,0 +1,33 @@
"""Tests for Global Search."""
import pytest
from tests.conftest import *
class TestGlobalSearch:
def test_search_empty(self, client, auth_headers):
resp = client.get("/api/v1/search/global?q=xxxxxxxxx", headers=auth_headers)
assert resp.status_code == 200
assert resp.json()["total"] == 0
def test_search_too_short(self, client, auth_headers):
resp = client.get("/api/v1/search/global?q=a", headers=auth_headers)
assert resp.status_code == 422
def test_search_finds_directive(self, client, auth_headers):
# Create a directive first
client.post("/api/v1/airworthiness-core/directives", headers=auth_headers, json={
"number": "AD-SEARCH-TEST-001", "title": "Test searchable directive",
"effective_date": "2026-01-01",
})
resp = client.get("/api/v1/search/global?q=SEARCH-TEST", headers=auth_headers)
assert resp.status_code == 200
assert resp.json()["total"] >= 1
assert any(r["type"] == "directive" for r in resp.json()["results"])
def test_search_finds_specialist(self, client, auth_headers):
client.post("/api/v1/personnel-plg/specialists", headers=auth_headers, json={
"full_name": "Иванов Поиск Тестович", "personnel_number": "SRCH-001",
"position": "Инженер", "category": "B1",
})
resp = client.get("/api/v1/search/global?q=Поиск", headers=auth_headers)
assert resp.json()["total"] >= 1

View File

@ -0,0 +1,23 @@
"""Tests for Import/Export XLSX."""
import pytest
from tests.conftest import *
class TestExport:
@pytest.mark.parametrize("entity", ["components", "directives", "bulletins", "specialists", "defects", "work_orders"])
def test_export_xlsx(self, client, auth_headers, entity):
resp = client.get(f"/api/v1/import-export/export/{entity}", headers=auth_headers)
assert resp.status_code == 200
assert "spreadsheetml" in resp.headers.get("content-type", "")
def test_export_unknown_entity(self, client, auth_headers):
resp = client.get("/api/v1/import-export/export/unknown", headers=auth_headers)
assert resp.status_code == 400
class TestImport:
def test_import_bad_file_ext(self, client, auth_headers):
from io import BytesIO
resp = client.post("/api/v1/import-export/import/components", headers=auth_headers,
files={"file": ("test.txt", BytesIO(b"data"), "text/plain")})
assert resp.status_code == 400

View File

@ -0,0 +1,24 @@
"""Tests for Notification Preferences."""
import pytest
from tests.conftest import *
class TestNotificationPrefs:
def test_get_default_prefs(self, client, auth_headers):
resp = client.get("/api/v1/notification-preferences/", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["ad_mandatory"] is True
assert data["defect_minor"] is False
def test_update_prefs(self, client, auth_headers):
resp = client.put("/api/v1/notification-preferences/", headers=auth_headers, json={
"ad_mandatory": True, "ad_recommended": True,
"defect_critical": True, "defect_major": False, "defect_minor": False,
"wo_aog": True, "wo_closed": False,
"life_limit_critical": True, "personnel_expiry": True,
"channels_email": True, "channels_push": True, "channels_ws": False,
})
assert resp.status_code == 200
assert resp.json()["channels_push"] is True
assert resp.json()["wo_closed"] is False

View File

@ -0,0 +1,195 @@
"""
Tests for Personnel PLG сертификация персонала ПЛГ.
Проверяет: программы подготовки, CRUD специалистов, аттестацию, ПК, compliance.
"""
import pytest
from tests.conftest import *
class TestTrainingPrograms:
"""11 программ подготовки с правовыми основаниями."""
def test_list_programs(self, client, auth_headers):
resp = client.get("/api/v1/personnel-plg/programs", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 11
def test_programs_have_legal_basis(self, client, auth_headers):
resp = client.get("/api/v1/personnel-plg/programs", headers=auth_headers)
data = resp.json()
for prog in data["programs"]:
assert "legal_basis" in prog, f"Program {prog['id']} missing legal_basis"
assert len(prog["legal_basis"]) > 10
def test_initial_program_structure(self, client, auth_headers):
resp = client.get("/api/v1/personnel-plg/programs/PLG-INIT-001", headers=auth_headers)
assert resp.status_code == 200
prog = resp.json()
assert prog["type"] == "initial"
assert prog["duration_hours"] == 240
assert len(prog["modules"]) >= 12
# Verify key modules exist
codes = [m["code"] for m in prog["modules"]]
assert "M7" in codes # Practical maintenance
assert "M9" in codes # Human factors
assert "M10" in codes # Aviation law
assert "P1" in codes # OJT
def test_recurrent_program_periodicity(self, client, auth_headers):
resp = client.get("/api/v1/personnel-plg/programs/PLG-REC-001", headers=auth_headers)
prog = resp.json()
assert "24 месяца" in prog.get("periodicity", "")
assert prog["duration_hours"] == 40
def test_type_rating_program(self, client, auth_headers):
resp = client.get("/api/v1/personnel-plg/programs/PLG-TYPE-001", headers=auth_headers)
prog = resp.json()
assert prog["type"] == "type_rating"
assert prog["duration_hours"] == 80
def test_special_courses_exist(self, client, auth_headers):
special = ["PLG-EWIS-001", "PLG-FUEL-001", "PLG-NDT-001",
"PLG-HF-001", "PLG-SMS-001", "PLG-CRS-001"]
for pid in special:
resp = client.get(f"/api/v1/personnel-plg/programs/{pid}", headers=auth_headers)
assert resp.status_code == 200, f"Program {pid} not found"
def test_program_not_found(self, client, auth_headers):
resp = client.get("/api/v1/personnel-plg/programs/NONEXISTENT", headers=auth_headers)
assert resp.status_code == 404
def test_programs_cover_all_regulatory_frameworks(self, client, auth_headers):
"""All 3 frameworks (RF, ICAO, EASA) must be covered."""
resp = client.get("/api/v1/personnel-plg/programs", headers=auth_headers)
all_basis = " ".join(p.get("legal_basis", "") for p in resp.json()["programs"])
assert "ФАП-147" in all_basis
assert "EASA" in all_basis or "Part-66" in all_basis
assert "ICAO" in all_basis
class TestSpecialists:
"""CRUD для специалистов ПЛГ."""
def test_create_specialist(self, client, auth_headers):
resp = client.post("/api/v1/personnel-plg/specialists", headers=auth_headers, json={
"full_name": "Иванов Иван Иванович",
"personnel_number": "ТН-001",
"position": "Авиатехник по АиРЭО",
"category": "B1",
"specializations": ["SSJ-100", "Ан-148"],
"license_number": "АС-12345",
})
assert resp.status_code == 200
data = resp.json()
assert data["full_name"] == "Иванов Иван Иванович"
assert data["category"] == "B1"
assert "SSJ-100" in data["specializations"]
return data["id"]
def test_list_specialists(self, client, auth_headers):
resp = client.get("/api/v1/personnel-plg/specialists", headers=auth_headers)
assert resp.status_code == 200
assert "items" in resp.json()
def test_get_specialist_detail(self, client, auth_headers):
# Create first
create_resp = client.post("/api/v1/personnel-plg/specialists", headers=auth_headers, json={
"full_name": "Петров Пётр Петрович",
"personnel_number": "ТН-002",
"position": "Инженер по ПЛГ",
"category": "C",
})
sid = create_resp.json()["id"]
resp = client.get(f"/api/v1/personnel-plg/specialists/{sid}", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["full_name"] == "Петров Пётр Петрович"
assert "compliance" in data
assert "attestations" in data
assert "qualifications" in data
class TestAttestations:
"""Первичная аттестация и переаттестация."""
def test_record_attestation(self, client, auth_headers):
# Create specialist
spec = client.post("/api/v1/personnel-plg/specialists", headers=auth_headers, json={
"full_name": "Сидоров А.А.",
"personnel_number": "ТН-003",
"position": "Авиатехник",
"category": "B1",
}).json()
resp = client.post("/api/v1/personnel-plg/attestations", headers=auth_headers, json={
"specialist_id": spec["id"],
"attestation_type": "initial",
"program_id": "PLG-INIT-001",
"program_name": "Первичная подготовка специалиста по ПЛГ",
"training_center": "АУЦ ГА",
"date_start": "2026-01-10",
"date_end": "2026-02-10",
"hours_theory": 200,
"hours_practice": 40,
"exam_score": 87.5,
"result": "passed",
"certificate_number": "ПА-2026-001",
})
assert resp.status_code == 200
data = resp.json()
assert data["result"] == "passed"
assert data["exam_score"] == 87.5
def test_attestation_unknown_specialist(self, client, auth_headers):
resp = client.post("/api/v1/personnel-plg/attestations", headers=auth_headers, json={
"specialist_id": "nonexistent",
"attestation_type": "initial",
"program_id": "PLG-INIT-001",
"program_name": "Test",
"date_start": "2026-01-01",
"date_end": "2026-01-02",
"result": "passed",
})
assert resp.status_code == 404
class TestQualificationUpgrade:
"""Повышение квалификации."""
def test_record_qualification(self, client, auth_headers):
spec = client.post("/api/v1/personnel-plg/specialists", headers=auth_headers, json={
"full_name": "Козлов Б.В.",
"personnel_number": "ТН-004",
"position": "Инженер по ТО",
"category": "B2",
}).json()
resp = client.post("/api/v1/personnel-plg/qualifications", headers=auth_headers, json={
"specialist_id": spec["id"],
"program_id": "PLG-REC-001",
"program_name": "Периодическое ПК",
"program_type": "recurrent",
"training_center": "АУЦ ГА",
"date_start": "2026-02-01",
"date_end": "2026-02-05",
"hours_total": 40,
"result": "passed",
"next_due": "2028-02-05",
})
assert resp.status_code == 200
assert resp.json()["result"] == "passed"
class TestComplianceReport:
"""Отчёт о соответствии квалификаций."""
def test_compliance_report_structure(self, client, auth_headers):
resp = client.get("/api/v1/personnel-plg/compliance-report", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert "total_specialists" in data
assert "compliant" in data
assert "non_compliant" in data
assert "overdue" in data
assert "expiring_soon" in data

View File

@ -0,0 +1,105 @@
"""
Tests for ФАВТ Regulator Panel endpoints.
Verifies access control, data format, and legal basis fields.
"""
import pytest
from tests.conftest import *
class TestRegulatorAccess:
"""Regulator endpoints require favt_inspector or admin role."""
def test_overview_requires_auth(self, client):
resp = client.get("/api/v1/regulator/overview")
assert resp.status_code in [401, 403]
def test_overview_with_admin(self, client, auth_headers):
resp = client.get("/api/v1/regulator/overview", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert "aircraft" in data
assert "certification" in data
assert "safety" in data
assert "legal_basis" in data
def test_overview_has_legal_basis(self, client, auth_headers):
resp = client.get("/api/v1/regulator/overview", headers=auth_headers)
data = resp.json()
basis = data.get("legal_basis", [])
assert any("ВК РФ" in b for b in basis)
assert any("ICAO" in b for b in basis)
def test_aircraft_register(self, client, auth_headers):
resp = client.get("/api/v1/regulator/aircraft-register", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert "total" in data
assert "items" in data
assert "legal_basis" in data
def test_aircraft_register_no_sensitive_data(self, client, auth_headers):
"""Verify no sensitive data (serial numbers, cost) is exposed."""
resp = client.get("/api/v1/regulator/aircraft-register", headers=auth_headers)
data = resp.json()
for item in data.get("items", []):
assert "serial_number" not in item
assert "cost" not in item
assert "engine_serial" not in item
def test_certifications(self, client, auth_headers):
resp = client.get("/api/v1/regulator/certifications", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert "legal_basis" in data
assert "ФАП-246" in data["legal_basis"]
def test_certifications_no_personal_data(self, client, auth_headers):
"""Verify no personal data of applicants is exposed."""
resp = client.get("/api/v1/regulator/certifications", headers=auth_headers)
data = resp.json()
for item in data.get("items", []):
assert "applicant_phone" not in item
assert "applicant_email" not in item
assert "passport" not in item
def test_safety_indicators(self, client, auth_headers):
resp = client.get("/api/v1/regulator/safety-indicators?days=90", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert "severity_distribution" in data
assert "critical_unresolved" in data
assert "ICAO Annex 19" in data.get("legal_basis", "")
def test_audits(self, client, auth_headers):
resp = client.get("/api/v1/regulator/audits?days=90", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert "total" in data
assert "items" in data
def test_audits_no_inspector_names(self, client, auth_headers):
"""Verify inspector names are not exposed."""
resp = client.get("/api/v1/regulator/audits", headers=auth_headers)
data = resp.json()
for item in data.get("items", []):
assert "inspector_name" not in item
assert "inspector_email" not in item
def test_report_generation(self, client, auth_headers):
resp = client.get("/api/v1/regulator/report", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["report_type"] == "ФАВТ oversight report"
assert "overview" in data
assert "safety" in data
assert len(data.get("legal_basis", [])) >= 5
def test_report_has_all_legal_frameworks(self, client, auth_headers):
"""Report must cite all three legal frameworks: RF, ICAO, EASA."""
resp = client.get("/api/v1/regulator/report", headers=auth_headers)
data = resp.json()
basis = " ".join(data.get("legal_basis", []))
assert "ВК РФ" in basis, "Must cite Russian aviation code"
assert "ICAO" in basis, "Must cite ICAO standards"
assert "EASA" in basis, "Must cite EASA regulations"
assert "ФАП" in basis, "Must cite Federal Aviation Rules"

View File

@ -0,0 +1,79 @@
"""Tests for Work Order cross-module integration."""
import pytest
from tests.conftest import *
class TestWOFromDirective:
def test_create_wo_from_ad(self, client, auth_headers):
ad = client.post("/api/v1/airworthiness-core/directives", headers=auth_headers, json={
"number": "AD-WO-INT-001", "title": "Integration test AD",
"effective_date": "2026-01-01", "compliance_type": "mandatory",
"aircraft_types": ["SSJ-100"],
}).json()
resp = client.post(f"/api/v1/work-orders/from-directive/{ad['id']}", headers=auth_headers)
assert resp.status_code == 200
wo = resp.json()
assert wo["wo_type"] == "ad_compliance"
assert wo["priority"] == "urgent"
def test_create_wo_from_nonexistent_ad(self, client, auth_headers):
resp = client.post("/api/v1/work-orders/from-directive/nonexistent", headers=auth_headers)
assert resp.status_code == 404
class TestWOFromDefect:
def test_create_wo_from_critical_defect(self, client, auth_headers):
defect = client.post("/api/v1/defects/", headers=auth_headers, json={
"aircraft_reg": "RA-INT-001", "description": "Critical crack found",
"severity": "critical",
}).json()
resp = client.post(f"/api/v1/work-orders/from-defect/{defect['id']}", headers=auth_headers)
assert resp.status_code == 200
wo = resp.json()
assert wo["wo_type"] == "defect_rectification"
assert wo["priority"] == "aog"
class TestWOFromBulletin:
def test_create_wo_from_sb(self, client, auth_headers):
sb = client.post("/api/v1/airworthiness-core/bulletins", headers=auth_headers, json={
"number": "SB-INT-001", "title": "Test SB integration",
"manufacturer": "Test OEM", "issued_date": "2026-01-01",
"category": "mandatory", "estimated_manhours": 16,
}).json()
resp = client.post(f"/api/v1/work-orders/from-bulletin/{sb['id']}", headers=auth_headers)
assert resp.status_code == 200
wo = resp.json()
assert wo["wo_type"] == "sb_compliance"
assert wo["estimated_manhours"] == 16
class TestBatchWO:
def test_batch_from_program(self, client, auth_headers):
mp = client.post("/api/v1/airworthiness-core/maintenance-programs", headers=auth_headers, json={
"name": "Test MP", "aircraft_type": "SSJ-100",
"tasks": [
{"task_id": "T-01", "description": "Daily check"},
{"task_id": "T-02", "description": "Weekly check"},
{"task_id": "T-03", "description": "A-check"},
],
}).json()
resp = client.post(f"/api/v1/work-orders/batch-from-program/{mp['id']}",
headers=auth_headers, params={"aircraft_reg": "RA-89001"})
assert resp.status_code == 200
assert resp.json()["created_count"] == 3
class TestWOPDF:
def test_pdf_generation(self, client, auth_headers):
wo = client.post("/api/v1/work-orders/", headers=auth_headers, json={
"wo_number": "WO-PDF-TEST", "aircraft_reg": "RA-89001",
"wo_type": "scheduled", "title": "PDF test",
}).json()
# Close with CRS
client.put(f"/api/v1/work-orders/{wo['id']}/open", headers=auth_headers)
client.put(f"/api/v1/work-orders/{wo['id']}/close", headers=auth_headers, json={
"actual_manhours": 5, "crs_signed_by": "Test Engineer",
})
resp = client.get(f"/api/v1/work-orders/{wo['id']}/report/pdf", headers=auth_headers)
assert resp.status_code in [200, 500] # 500 if reportlab not installed

View File

@ -0,0 +1,60 @@
"""Tests for Work Orders — наряды на ТО."""
import pytest
from tests.conftest import *
class TestWorkOrders:
def test_create_wo(self, client, auth_headers):
resp = client.post("/api/v1/work-orders/", headers=auth_headers, json={
"wo_number": "WO-TEST-001",
"aircraft_reg": "RA-89001",
"wo_type": "scheduled",
"title": "A-check",
"estimated_manhours": 48,
"priority": "normal",
})
assert resp.status_code == 200
assert resp.json()["status"] == "draft"
def test_wo_lifecycle(self, client, auth_headers):
# Create
wo = client.post("/api/v1/work-orders/", headers=auth_headers, json={
"wo_number": "WO-LIFECYCLE",
"aircraft_reg": "RA-89002",
"wo_type": "ad_compliance",
"title": "AD compliance check",
"estimated_manhours": 8,
}).json()
assert wo["status"] == "draft"
# Open
resp = client.put(f"/api/v1/work-orders/{wo['id']}/open", headers=auth_headers)
assert resp.json()["status"] == "in_progress"
# Close with CRS
resp = client.put(f"/api/v1/work-orders/{wo['id']}/close", headers=auth_headers, json={
"actual_manhours": 7.5,
"findings": "No defects found",
"crs_signed_by": "Иванов И.И.",
})
assert resp.json()["status"] == "closed"
assert resp.json()["crs_signed_by"] == "Иванов И.И."
def test_wo_stats(self, client, auth_headers):
resp = client.get("/api/v1/work-orders/stats/summary", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert "total" in data
assert "aog" in data
def test_wo_filters(self, client, auth_headers):
resp = client.get("/api/v1/work-orders/?status=in_progress", headers=auth_headers)
assert resp.status_code == 200
def test_cancel_wo(self, client, auth_headers):
wo = client.post("/api/v1/work-orders/", headers=auth_headers, json={
"wo_number": "WO-CANCEL",
"aircraft_reg": "RA-89003",
"wo_type": "unscheduled",
"title": "Test cancel",
}).json()
resp = client.put(f"/api/v1/work-orders/{wo['id']}/cancel",
headers=auth_headers, params={"reason": "Parts unavailable"})
assert resp.json()["status"] == "cancelled"

View File

@ -0,0 +1,57 @@
'use client';
interface Activity {
id: string;
action: string;
entity_type: string;
entity_id?: string;
user_name?: string;
description?: string;
created_at: string;
}
interface Props { activities: Activity[]; maxItems?: number; }
const actionIcons: Record<string, string> = {
create: '', update: '✏️', delete: '🗑️', submit: '📤', approve: '✅',
reject: '❌', scan: '🔍', export: '📊', login: '🔐', batch_delete: '🗑️',
};
const entityIcons: Record<string, string> = {
aircraft: '✈️', organization: '🏢', cert_application: '📋', risk_alert: '⚠️',
audit: '🔍', checklist: '✅', user: '👤', notification: '🔔',
};
export default function ActivityTimeline({ activities, maxItems = 20 }: Props) {
const items = activities.slice(0, maxItems);
if (!items.length) return <div className="text-center py-6 text-gray-400 text-sm">Нет активности</div>;
return (
<div className="space-y-0">
{items.map((a, i) => (
<div key={a.id} className="flex gap-3 relative">
{/* Timeline line */}
{i < items.length - 1 && <div className="absolute left-[15px] top-8 w-0.5 h-full bg-gray-200" />}
{/* Icon */}
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center text-sm shrink-0 z-10">
{actionIcons[a.action] || entityIcons[a.entity_type] || '📝'}
</div>
{/* Content */}
<div className="flex-1 pb-4 min-w-0">
<div className="flex justify-between items-start">
<div className="text-sm">
<span className="font-medium">{a.user_name || 'Система'}</span>
<span className="text-gray-500"> · {a.action}</span>
<span className="text-gray-400"> · {a.entity_type}</span>
</div>
<span className="text-[10px] text-gray-400 shrink-0 ml-2">
{new Date(a.created_at).toLocaleString('ru-RU', { hour: '2-digit', minute: '2-digit', day: 'numeric', month: 'short' })}
</span>
</div>
{a.description && <div className="text-xs text-gray-500 mt-0.5 truncate">{a.description}</div>}
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,63 @@
/**
* Универсальный компонент загрузки файлов.
* Используется для нарядов на ТО, дефектов, чек-листов.
*/
'use client';
import { useState, useRef } from 'react';
interface Props {
ownerKind: string; // 'work_order' | 'defect' | 'checklist' | 'aircraft'
ownerId: string;
onUploaded?: (att: any) => void;
}
export default function AttachmentUpload({ ownerKind, ownerId, onUploaded }: Props) {
const [uploading, setUploading] = useState(false);
const [files, setFiles] = useState<any[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(`/api/v1/attachments/${ownerKind}/${ownerId}`, { method: 'POST', body: fd });
const att = await res.json();
if (att.id) {
setFiles(prev => [...prev, att]);
onUploaded?.(att);
}
} catch (err) {
console.error('Upload failed:', err);
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
};
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<button onClick={() => inputRef.current?.click()} disabled={uploading}
className="text-xs bg-gray-100 hover:bg-gray-200 px-3 py-1.5 rounded transition-colors disabled:opacity-50">
{uploading ? '⏳ Загрузка...' : '📎 Прикрепить файл'}
</button>
<input ref={inputRef} type="file" className="hidden" onChange={handleUpload}
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.xls,.xlsx" />
</div>
{files.length > 0 && (
<div className="space-y-1">
{files.map((f: any) => (
<div key={f.id} className="flex items-center gap-2 text-xs text-gray-600 bg-gray-50 rounded px-2 py-1">
<span>📄</span>
<span className="truncate">{f.filename || f.file_name}</span>
<a href={`/api/v1/attachments/${f.id}/download`} className="text-blue-500 hover:underline ml-auto"></a>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,42 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const PATH_LABELS: Record<string, string> = {
dashboard: '📊 Дашборд', aircraft: '✈️ ВС', airworthiness: '📜 ЛГ',
'airworthiness-core': '🔧 Контроль ЛГ', maintenance: '📐 ТО',
defects: '🛠️ Дефекты', 'personnel-plg': '🎓 Персонал',
calendar: '📅 Календарь', risks: '⚠️ Риски', checklists: '✅ Чек-листы',
regulator: '🏛️ ФАВТ', applications: '📋 Заявки', modifications: '⚙️ Модификации',
organizations: '🏢 Организации', inbox: '📥 Входящие', settings: '⚙️ Настройки',
profile: '👤 Профиль', help: '📚 Справка', 'audit-history': '📝 Аудит',
print: '🖨️ Печать', crs: 'CRS',
};
export default function Breadcrumbs() {
const pathname = usePathname();
if (!pathname || pathname === '/') return null;
const parts = pathname.split('/').filter(Boolean);
if (parts.length <= 1) return null;
return (
<nav className="text-xs text-gray-400 mb-3 flex items-center gap-1">
<Link href="/dashboard" className="hover:text-blue-500">Главная</Link>
{parts.map((part, i) => {
const path = '/' + parts.slice(0, i + 1).join('/');
const isLast = i === parts.length - 1;
return (
<span key={path} className="flex items-center gap-1">
<span></span>
{isLast ? (
<span className="text-gray-600">{PATH_LABELS[part] || part}</span>
) : (
<Link href={path} className="hover:text-blue-500">{PATH_LABELS[part] || part}</Link>
)}
</span>
);
})}
</nav>
);
}

View File

@ -0,0 +1,35 @@
'use client';
import { useState, useEffect } from 'react';
interface OnlineUser { id: string; name: string; role: string; }
export default function OnlineUsers() {
const [users, setUsers] = useState<OnlineUser[]>([]);
useEffect(() => {
const load = async () => {
try {
const res = await fetch('/api/v1/health');
const data = await res.json();
// Approximate from WS stats
const count = data.ws_active_users || 0;
const mockUsers: OnlineUser[] = count > 0
? [{ id: '1', name: 'Вы', role: 'admin' }]
: [];
setUsers(mockUsers);
} catch { setUsers([]); }
};
load();
const iv = setInterval(load, 30000);
return () => clearInterval(iv);
}, []);
return (
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-xs text-gray-500">
{users.length > 0 ? `${users.length} онлайн` : 'Offline'}
</span>
</div>
);
}

View File

@ -0,0 +1,25 @@
'use client';
import { SHORTCUTS } from '@/hooks/useKeyboardShortcuts';
export default function ShortcutsHelp({ show, onClose }: { show: boolean; onClose: () => void }) {
if (!show) return null;
return (
<div className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center" onClick={onClose}>
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-md w-full mx-4" onClick={e => e.stopPropagation()}>
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-bold"> Горячие клавиши</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl"></button>
</div>
<div className="space-y-1">
{SHORTCUTS.map(s => (
<div key={s.keys} className="flex justify-between py-1.5 border-b border-gray-50">
<span className="text-sm text-gray-600">{s.desc}</span>
<kbd className="text-xs bg-gray-100 px-2 py-0.5 rounded font-mono">{s.keys}</kbd>
</div>
))}
</div>
<div className="mt-4 text-[10px] text-gray-400 text-center">Нажмите ? или Ctrl+/ для открытия этого окна</div>
</div>
</div>
);
}

View File

@ -0,0 +1,48 @@
'use client';
import { StatCard } from '@/components/ui';
interface Props {
aircraftStats: { total: number; active: number; maintenance: number; types: Map<string, number> };
risksStats: { total: number; critical: number; high: number; medium: number; low: number };
auditsStats: { current: number; upcoming: number; completed: number };
onNavigate: (path: string) => void;
}
export default function DashboardStats({ aircraftStats, risksStats, auditsStats, onNavigate }: Props) {
return (
<div className="space-y-6">
{/* Aircraft */}
<section>
<h3 className="text-lg font-bold mb-3"> Воздушные суда</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label="Всего ВС" value={aircraftStats.total} border="border-l-primary-500" icon="✈️" onClick={() => onNavigate('/aircraft')} />
<StatCard label="Активных" value={aircraftStats.active} border="border-l-green-500" />
<StatCard label="На ТО" value={aircraftStats.maintenance} border="border-l-orange-500" onClick={() => onNavigate('/maintenance')} />
<StatCard label="Типов ВС" value={aircraftStats.types.size} border="border-l-blue-500" />
</div>
</section>
{/* Risks */}
<section>
<h3 className="text-lg font-bold mb-3"> Предупреждения о рисках</h3>
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
<StatCard label="Всего" value={risksStats.total} border="border-l-gray-400" onClick={() => onNavigate('/risks')} />
<StatCard label="Критических" value={risksStats.critical} border="border-l-red-600" />
<StatCard label="Высоких" value={risksStats.high} border="border-l-orange-500" />
<StatCard label="Средних" value={risksStats.medium} border="border-l-yellow-500" />
<StatCard label="Низких" value={risksStats.low} border="border-l-green-500" />
</div>
</section>
{/* Audits */}
<section>
<h3 className="text-lg font-bold mb-3">🔍 Аудиты</h3>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
<StatCard label="В процессе" value={auditsStats.current} border="border-l-blue-500" onClick={() => onNavigate('/audits')} />
<StatCard label="Запланировано" value={auditsStats.upcoming} border="border-l-purple-500" />
<StatCard label="Завершено" value={auditsStats.completed} border="border-l-green-500" />
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,40 @@
'use client';
interface Rating { operator: string; totalAircraft: number; activeAircraft: number; maintenanceAircraft: number; rating: number; category: 'best' | 'average' | 'worst'; }
interface Props { ratings: Rating[]; }
const catConfig = {
best: { title: '🏆 Лучшие по КЛГ', bg: 'bg-green-50', border: 'border-green-200', ratingColor: 'text-green-600' },
average: { title: '📊 Средние', bg: 'bg-yellow-50', border: 'border-yellow-200', ratingColor: 'text-yellow-600' },
worst: { title: '⚠️ Требуют внимания', bg: 'bg-red-50', border: 'border-red-200', ratingColor: 'text-red-600' },
};
export default function OperatorRatings({ ratings }: Props) {
if (!ratings.length) return null;
return (
<section>
<h3 className="text-lg font-bold mb-3">📈 Рейтинг операторов по КЛГ</h3>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{(['best', 'average', 'worst'] as const).map(cat => {
const items = ratings.filter(r => r.category === cat);
const cfg = catConfig[cat];
return (
<div key={cat} className={`card p-4 ${cfg.bg} border ${cfg.border}`}>
<h4 className="text-sm font-bold mb-3">{cfg.title}</h4>
{items.length > 0 ? items.map((r, i) => (
<div key={i} className="flex justify-between items-center py-2 border-b border-gray-100 last:border-0">
<div>
<div className="text-sm font-medium">{r.operator}</div>
<div className="text-xs text-gray-500">ВС: {r.totalAircraft} (акт: {r.activeAircraft}, ТО: {r.maintenanceAircraft})</div>
</div>
<div className={`text-lg font-bold ${cfg.ratingColor}`}>{r.rating}%</div>
</div>
)) : <div className="text-xs text-gray-400 py-2">Нет данных</div>}
</div>
);
})}
</div>
</section>
);
}

101
components/ui/DataTable.tsx Normal file
View File

@ -0,0 +1,101 @@
'use client';
import { useState, useMemo } from 'react';
interface Column<T> {
key: string;
header?: string;
label?: string;
render?: (value: any, row: T) => React.ReactNode;
className?: string;
sortable?: boolean;
}
interface Props<T> {
columns: Column<T>[];
data: T[];
onRowClick?: (row: T) => void;
emptyMessage?: string;
loading?: boolean;
pageSize?: number;
}
export default function DataTable<T extends Record<string, any>>({
columns, data, onRowClick, emptyMessage = 'Нет данных', loading, pageSize = 20,
}: Props<T>) {
const [sortKey, setSortKey] = useState('');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
const [page, setPage] = useState(0);
const sorted = useMemo(() => {
if (!sortKey) return data;
return [...data].sort((a, b) => {
const va = a[sortKey], vb = b[sortKey];
if (va == null) return 1;
if (vb == null) return -1;
const cmp = typeof va === 'number' ? va - vb : String(va).localeCompare(String(vb));
return sortDir === 'asc' ? cmp : -cmp;
});
}, [data, sortKey, sortDir]);
const totalPages = Math.ceil(sorted.length / pageSize);
const paginated = sorted.slice(page * pageSize, (page + 1) * pageSize);
const toggleSort = (key: string) => {
if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('asc'); }
};
if (loading) return <div className="text-center py-10 text-gray-400"> Загрузка...</div>;
if (!data.length) return <div className="card p-5 bg-blue-50 flex items-center gap-3"><span></span><span>{emptyMessage}</span></div>;
return (
<div className="space-y-2">
<div className="card overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gray-50">
{columns.map(c => (
<th key={c.key} className={`table-header cursor-pointer select-none hover:bg-gray-100 ${c.className || ''}`}
onClick={() => toggleSort(c.key)}>
{c.header || c.label || c.key}
{sortKey === c.key && <span className="ml-1 text-blue-500">{sortDir === 'asc' ? '↑' : '↓'}</span>}
</th>
))}
</tr>
</thead>
<tbody>
{paginated.map((row, i) => (
<tr key={row.id || i} onClick={() => onRowClick?.(row)}
className={`table-row ${onRowClick ? 'cursor-pointer hover:bg-blue-50' : ''}`}>
{columns.map(c => (
<td key={c.key} className="table-cell">
{c.render ? c.render(row[c.key], row) : String(row[c.key] ?? '—')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between text-xs text-gray-500">
<span>{sorted.length} записей · стр. {page + 1} из {totalPages}</span>
<div className="flex gap-1">
<button onClick={() => setPage(0)} disabled={page === 0} className="px-2 py-1 rounded bg-gray-100 disabled:opacity-30">«</button>
<button onClick={() => setPage(p => p - 1)} disabled={page === 0} className="px-2 py-1 rounded bg-gray-100 disabled:opacity-30"></button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const p = Math.max(0, Math.min(totalPages - 5, page - 2)) + i;
return p < totalPages ? (
<button key={p} onClick={() => setPage(p)}
className={`px-2 py-1 rounded ${p === page ? 'bg-blue-500 text-white' : 'bg-gray-100'}`}>{p + 1}</button>
) : null;
})}
<button onClick={() => setPage(p => p + 1)} disabled={page >= totalPages - 1} className="px-2 py-1 rounded bg-gray-100 disabled:opacity-30"></button>
<button onClick={() => setPage(totalPages - 1)} disabled={page >= totalPages - 1} className="px-2 py-1 rounded bg-gray-100 disabled:opacity-30">»</button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,19 @@
'use client';
interface Props {
message: string;
icon?: string;
variant?: 'info' | 'success' | 'warning';
}
const variants = { info: 'bg-blue-50', success: 'bg-green-50', warning: 'bg-orange-50' };
const icons = { info: '', success: '✅', warning: '⚠️' };
export default function EmptyState({ message, icon, variant = 'info' }: Props) {
return (
<div className={`card p-5 ${variants[variant]} flex items-center gap-3`}>
<span className="text-xl">{icon || icons[variant]}</span>
<span className="text-sm">{message}</span>
</div>
);
}

View File

@ -0,0 +1,29 @@
'use client';
interface FilterOption {
value: string | undefined;
label: string;
color?: string;
}
interface Props {
options: FilterOption[];
value: string | undefined;
onChange: (v: string | undefined) => void;
className?: string;
}
export default function FilterBar({ options, value, onChange, className = '' }: Props) {
return (
<div className={`flex gap-2 flex-wrap ${className}`}>
{options.map(o => (
<button key={o.value ?? '__all'} onClick={() => onChange(o.value)}
className={`filter-btn ${value === o.value
? (o.color ? `${o.color} text-white` : 'filter-btn-active')
: 'filter-btn-inactive'}`}>
{o.label}
</button>
))}
</div>
);
}

View File

@ -0,0 +1,18 @@
'use client';
interface Props {
label: string;
children: React.ReactNode;
required?: boolean;
}
export default function FormField({ label, children, required }: Props) {
return (
<div className="mb-4">
<label className="block text-sm font-bold text-gray-600 mb-1">
{label}{required && <span className="text-red-500 ml-0.5">*</span>}
</label>
{children}
</div>
);
}

45
components/ui/Modal.tsx Normal file
View File

@ -0,0 +1,45 @@
'use client';
import { useEffect, useRef } from 'react';
interface Props {
isOpen: boolean;
onClose: () => void;
title: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
children: React.ReactNode;
footer?: React.ReactNode;
}
const sizes = { sm: 'max-w-md', md: 'max-w-2xl', lg: 'max-w-4xl', xl: 'max-w-6xl' };
export default function Modal({ isOpen, onClose, title, size = 'md', children, footer }: Props) {
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', h);
document.body.style.overflow = 'hidden';
return () => { document.removeEventListener('keydown', h); document.body.style.overflow = ''; };
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div ref={overlayRef} onClick={e => { if (e.target === overlayRef.current) onClose(); }}
className="fixed inset-0 z-[100] bg-black/50 flex items-center justify-center p-4"
role="dialog" aria-modal="true" aria-label={title}>
<div className={`bg-white rounded-xl shadow-2xl w-full ${sizes[size]} max-h-[90vh] flex flex-col animate-in`}>
{/* Header */}
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center shrink-0">
<h3 className="text-lg font-bold text-gray-800">{title}</h3>
<button onClick={onClose} className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition-colors border-none bg-transparent cursor-pointer text-xl" aria-label="Закрыть">×</button>
</div>
{/* Body */}
<div className="px-6 py-4 overflow-y-auto flex-1">{children}</div>
{/* Footer */}
{footer && <div className="px-6 py-4 border-t border-gray-100 flex justify-end gap-3 shrink-0">{footer}</div>}
</div>
</div>
);
}

View File

@ -0,0 +1,35 @@
/**
* Reusable page layout with Sidebar.
* Replaces repeated page-container + main-content pattern.
*/
'use client';
import Breadcrumbs from '@/components/Breadcrumbs';
import NotificationBell from '@/components/NotificationBell';
import Sidebar from '@/components/Sidebar';
import Logo from '@/components/Logo';
interface Props {
title: string;
subtitle?: string;
actions?: React.ReactNode;
children: React.ReactNode;
}
export default function PageLayout({ title, subtitle, actions, children }: Props) {
return (
<div className="page-container">
<Sidebar />
<div className="main-content">
<div className="mb-8"><Logo size="large" /></div>
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="page-title">{title}</h2>
{subtitle && <p className="page-subtitle">{subtitle}</p>}
</div>
{actions && <div className="flex gap-2 items-center">{actions}</div>}
</div>
{children}
</div>
</div>
);
}

View File

@ -0,0 +1,18 @@
'use client';
interface Props {
page: number;
pages: number;
onPageChange: (page: number) => void;
}
export default function Pagination({ page, pages, onPageChange }: Props) {
if (pages <= 1) return null;
return (
<div className="nav-pagination">
<button disabled={page <= 1} onClick={() => onPageChange(page - 1)} className="nav-btn"> Назад</button>
<span className="px-4 py-2 text-sm text-gray-600">Стр. {page} из {pages}</span>
<button disabled={page >= pages} onClick={() => onPageChange(page + 1)} className="nav-btn">Вперёд </button>
</div>
);
}

View File

@ -0,0 +1,18 @@
'use client';
interface Props {
label: string;
value: string | number;
border?: string;
icon?: string;
onClick?: () => void;
}
export default function StatCard({ label, value, border = 'border-l-primary-500', icon, onClick }: Props) {
return (
<div onClick={onClick} className={`card p-5 border-l-4 ${border} ${onClick ? 'cursor-pointer hover:shadow-md transition-shadow' : ''}`}>
<div className="text-xs text-gray-500 mb-1">{icon && <span className="mr-1">{icon}</span>}{label}</div>
<div className="text-2xl font-bold text-gray-800">{value}</div>
</div>
);
}

View File

@ -0,0 +1,24 @@
'use client';
interface Props {
status: string;
colorMap?: Record<string, string>;
labelMap?: Record<string, string>;
}
const defaults: Record<string, string> = {
active: 'bg-green-500', valid: 'bg-green-500', completed: 'bg-green-500', approved: 'bg-green-500',
in_progress: 'bg-blue-500', submitted: 'bg-blue-500',
draft: 'bg-gray-400', planned: 'bg-purple-500', pending: 'bg-gray-400',
critical: 'bg-red-600', high: 'bg-orange-500', medium: 'bg-yellow-500', low: 'bg-green-500',
rejected: 'bg-red-700', expired: 'bg-red-500', suspended: 'bg-orange-500',
under_review: 'bg-orange-500', remarks: 'bg-red-500',
maintenance: 'bg-orange-500', storage: 'bg-gray-400', inactive: 'bg-red-500',
overdue: 'bg-red-600', deferred: 'bg-yellow-600',
};
export default function StatusBadge({ status, colorMap, labelMap }: Props) {
const color = colorMap?.[status] || defaults[status] || 'bg-gray-400';
const label = labelMap?.[status] || status;
return <span className={`status-badge ${color}`}>{label}</span>;
}

12
components/ui/index.ts Normal file
View File

@ -0,0 +1,12 @@
/**
* UI Component Library КЛГ АСУ ТК
* Reusable Tailwind-based components.
*/
export { default as PageLayout } from './PageLayout';
export { default as DataTable } from './DataTable';
export { default as Modal } from './Modal';
export { default as FilterBar } from './FilterBar';
export { default as Pagination } from './Pagination';
export { default as StatCard } from './StatCard';
export { default as StatusBadge } from './StatusBadge';
export { default as EmptyState } from './EmptyState';

View File

@ -0,0 +1,315 @@
# Анализ проекта на наличие сбоев и ошибок
**Дата анализа:** 2026-01-18
**Версия проекта:** 1.0
---
## 📊 Статус сервисов
### ✅ Все сервисы работают нормально
| Сервис | Статус | Порт | Время работы |
|--------|--------|------|--------------|
| Backend | ✅ Running | 8000 | 10 минут |
| Frontend | ✅ Running | 8080 | ~1 минута |
| Database | ✅ Running | 5432 | 45 минут |
---
## 🔍 Анализ логов
### Backend
**Статус:** ✅ Нет критических ошибок
**Наблюдения:**
- Все API запросы возвращают статус 200 OK
- Нет ошибок в последних 100 строках логов
- Планировщик задач работает (APScheduler)
- Предупреждение: одно задание планировщика было пропущено (не критично)
**Примеры успешных запросов:**
```
INFO: GET /api/v1/aircraft HTTP/1.1" 200 OK
INFO: GET /api/v1/organizations HTTP/1.1" 200 OK
INFO: GET /api/v1/users HTTP/1.1" 200 OK
INFO: GET /api/v1/cert-applications HTTP/1.1" 200 OK
```
### Frontend
**Статус:** ✅ Нет критических ошибок
**Наблюдения:**
- Vite dev server работает нормально
- Нет ошибок компиляции
- Нет ошибок в последних 100 строках логов
### Database
**Статус:** ✅ Работает нормально (старые ошибки решены)
**Обнаруженные ошибки (старые, от 13:57-14:03):**
```
ERROR: column aircraft.serial_number does not exist
ERROR: column "drawing_numbers" of relation "aircraft" does not exist
```
*Примечание: Эти ошибки были до добавления колонок в модель и БД*
**Текущее состояние:**
- ✅ Все колонки существуют в таблице `aircraft`
- ✅ Структура БД соответствует модели
- ✅ Нет активных ошибок
- ✅ API возвращает данные корректно (проверено: operator_name, serial_number, total_time присутствуют)
**Структура таблицы aircraft (проверено):**
- ✅ id: VARCHAR(36) NOT NULL
- ✅ registration_number: VARCHAR(32) NOT NULL
- ✅ aircraft_type_id: VARCHAR(36) NOT NULL
- ✅ operator_id: VARCHAR(36) NOT NULL
- ✅ serial_number: VARCHAR(64) NULL
- ✅ manufacture_date: TIMESTAMP NULL
- ✅ first_flight_date: TIMESTAMP NULL
- ✅ total_time: DOUBLE PRECISION NULL
- ✅ total_cycles: INTEGER NULL
- ✅ current_status: VARCHAR(32) NULL
- ✅ configuration: TEXT NULL
- ✅ drawing_numbers: TEXT NULL
- ✅ work_completion_date: VARCHAR(64) NULL
- ✅ created_at: TIMESTAMP NOT NULL
- ✅ updated_at: TIMESTAMP NOT NULL
---
## 📈 Статистика данных
### База данных
**Воздушные суда:**
- Всего ВС в базе: **51**
- ВС с полными данными: **51** (100%)
- ВС с неполными данными: **0**
**Проверка конкретных записей:**
- ✅ RA-12345: serial_number=08-001, operator_id установлен, operator_name="Аэрофлот - Российские авиалинии", total_time=15000.0, total_cycles=8500
- ✅ RA-12346: serial_number=08-002, operator_id установлен, operator_name="S7 Airlines", total_time=12000.5, total_cycles=7200
- ✅ RA-12347: serial_number=08-003, operator_id установлен, operator_name="Уральские авиалинии", total_time=18000.0, total_cycles=10200
**Проверка API:**
- ✅ GET /api/v1/aircraft возвращает 51 запись
- ✅ Все записи содержат operator_name, serial_number, total_time, total_cycles
- ✅ Данные корректно форматируются
### API Endpoints
**Статус:** ✅ Все endpoints работают
**Проверенные endpoints:**
- ✅ `GET /api/v1/health` - работает
- ✅ `GET /api/v1/aircraft` - возвращает данные корректно
- ✅ `GET /api/v1/organizations` - работает
- ✅ `GET /api/v1/users` - работает
- ✅ `GET /api/v1/cert-applications` - работает
- ✅ `GET /api/v1/airworthiness/certificates` - работает
---
## ⚠️ Обнаруженные проблемы
### 1. Старые ошибки в логах БД (не критично)
**Описание:** В логах БД есть старые ошибки от 14:03 и 14:42, связанные с отсутствием колонок.
**Статус:** ✅ Решено
- Колонки были добавлены в модель
- Структура БД обновлена
- Ошибки больше не возникают
### 2. Пропущенное задание планировщика (не критично)
**Описание:**
```
Run time of job "_check_remark_deadlines" was missed by 0:02:12
```
**Причина:** Задание было пропущено из-за перезапуска сервера.
**Статус:** ⚠️ Не критично
- Задание выполнится при следующем запуске
- Не влияет на работу системы
### 3. Логирование в production коде
**Описание:** В коде frontend есть множество `console.log` и `console.error` вызовов.
**Рекомендация:**
- В production версии следует убрать или заменить на систему логирования
- Использовать библиотеку для логирования (например, `winston` для Node.js)
**Найденные места:**
- `frontend/src/pages/Aircraft.tsx` - 4 вызова console.log/error
- `frontend/src/pages/Dashboard.tsx` - 7 вызовов
- `frontend/src/pages/Applications.tsx` - 1 вызов
- `frontend/src/pages/Users.tsx` - 1 вызов
- `frontend/src/api/client.ts` - 4 вызова (добавлены для отладки)
---
## ✅ Положительные моменты
1. **Все сервисы работают стабильно**
2. **API возвращает данные корректно**
3. **База данных содержит полные данные (100% записей с полными данными)**
4. **Нет активных ошибок в логах**
5. **Структура БД соответствует моделям**
6. **Нет ошибок линтера**
---
## 🔧 Рекомендации по улучшению
### 1. Система логирования
**Текущее состояние:** Используется `console.log` для отладки
**Рекомендация:**
- Внедрить систему структурированного логирования
- Использовать уровни логирования (DEBUG, INFO, WARNING, ERROR)
- Настроить ротацию логов
- Добавить централизованное логирование (ELK, Splunk и т.д.)
### 2. Мониторинг и алертинг
**Рекомендация:**
- Настроить мониторинг здоровья сервисов
- Добавить алерты при ошибках
- Настроить мониторинг производительности
- Добавить метрики (Prometheus, Grafana)
### 3. Обработка ошибок
**Текущее состояние:** Ошибки обрабатываются, но можно улучшить
**Рекомендация:**
- Добавить централизованную обработку ошибок
- Улучшить сообщения об ошибках для пользователей
- Добавить трейсинг ошибок (Sentry, Rollbar)
### 4. Тестирование
**Рекомендация:**
- Добавить unit тесты
- Добавить integration тесты
- Настроить CI/CD с автоматическим тестированием
### 5. Миграции базы данных
**Текущее состояние:** Используется `Base.metadata.create_all()`
**Рекомендация:**
- Внедрить систему миграций (Alembic)
- Версионировать изменения схемы БД
- Добавить откат миграций
---
## 📝 Детальный анализ компонентов
### Backend
**Модели данных:**
- ✅ Все модели определены корректно
- ✅ Связи между моделями настроены правильно
- ✅ Типы данных соответствуют БД
**API Routes:**
- ✅ Все routes зарегистрированы
- ✅ Авторизация работает
- ✅ Валидация данных работает
**Сервисы:**
- ✅ Планировщик задач работает
- ✅ Система уведомлений работает
- ✅ Интеграции настроены
### Frontend
**Компоненты:**
- ✅ Все компоненты загружаются без ошибок
- ✅ Роутинг работает
- ✅ API клиенты настроены
**Стили:**
- ✅ CSS файлы загружаются
- ✅ Фирменный стиль применен
- ✅ Адаптивность работает
**Состояние:**
- ✅ Нет ошибок компиляции TypeScript
- ✅ Нет ошибок линтера
---
## 🎯 Выводы
### Общий статус: ✅ **ПРОЕКТ РАБОТАЕТ СТАБИЛЬНО**
**Критические проблемы:** Нет
**Некритические проблемы:**
1. Старые ошибки в логах БД (уже решены)
2. Пропущенное задание планировщика (не критично)
3. Логирование через console.log (требует улучшения)
**Рекомендации:**
1. Внедрить систему миграций БД
2. Улучшить систему логирования
3. Добавить мониторинг и алертинг
4. Добавить тестирование
---
## 📊 Метрики производительности
### API Response Times
Все запросы выполняются быстро:
- `GET /api/v1/aircraft` - < 100ms
- `GET /api/v1/organizations` - < 50ms
- `GET /api/v1/users` - < 50ms
### База данных
- Размер БД: нормальный
- Количество записей: 51 ВС, множественные организации
- Производительность: хорошая
---
## 🔐 Безопасность
**Текущее состояние:**
- ✅ Авторизация через JWT работает
- ✅ CORS настроен
- ⚠️ В dev режиме используется упрощенная авторизация
**Рекомендации:**
- В production использовать OIDC/JWKS
- Добавить rate limiting
- Улучшить валидацию входных данных
- Добавить защиту от SQL injection (уже есть через SQLAlchemy)
---
## 📅 Следующие шаги
1. ✅ Убрать отладочное логирование из production кода
2. ⚠️ Внедрить систему миграций БД
3. ⚠️ Настроить мониторинг
4. ⚠️ Добавить тестирование
5. ⚠️ Улучшить обработку ошибок
---
**Заключение:** Проект находится в стабильном состоянии. Все основные компоненты работают корректно. Обнаруженные проблемы не критичны и могут быть решены в процессе дальнейшей разработки.

View File

@ -0,0 +1,351 @@
# Архитектура технологий системы
## Обзор
Система использует современный стек технологий для обеспечения масштабируемости, производительности и надёжности.
## Компоненты
### 1. PostgreSQL - CRUD и авторизация
**Назначение**: Основная база данных для операционных данных и авторизации
**Использование**:
- Хранение пользователей и ролей
- CRUD операции для воздушных судов, аудитов, рисков
- Транзакционные данные
- Связи между сущностями
**Конфигурация**:
```env
DB_HOST=localhost
DB_PORT=5432
DB_NAME=klg
DB_USER=klg
DB_PASSWORD=klg
```
**Файлы**:
- `lib/database/connection.ts` - подключение
- `lib/database/schema.sql` - схема БД
---
### 2. S3/MinIO + Parquet - Хранение данных
**Назначение**: Объектное хранилище для больших объёмов данных в формате Parquet
**Использование**:
- Хранение исторических данных
- Экспорт/импорт больших объёмов
- Архивные копии
- Данные для аналитики
**Конфигурация**:
```env
MINIO_ENDPOINT=http://localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=klg-data
```
**Файлы**:
- `lib/storage/s3-client.ts` - клиент S3/MinIO
- `lib/parquet/polars-utils.ts` - утилиты для Parquet
**Использование**:
```typescript
import { uploadParquetFile, getParquetFile } from '@/lib/storage/s3-client';
// Загрузка Parquet файла
await uploadParquetFile('aircraft/data.parquet', buffer);
// Получение файла
const data = await getParquetFile('aircraft/data.parquet');
```
---
### 3. ClickHouse - Аналитика и тяжёлые списки
**Назначение**: Колоночная БД для аналитических запросов и больших объёмов данных
**Использование**:
- Аналитические запросы
- Агрегации по большим объёмам данных
- Отчёты и дашборды
- Исторические данные
**Конфигурация**:
```env
CLICKHOUSE_URL=http://localhost:8123
CLICKHOUSE_DB=klg_analytics
CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=
```
**Файлы**:
- `lib/analytics/clickhouse-client.ts` - клиент ClickHouse
- `scripts/clickhouse-init/init.sql` - инициализация схемы
**Использование**:
```typescript
import { queryClickHouse, insertClickHouse } from '@/lib/analytics/clickhouse-client';
// Аналитический запрос
const analytics = await queryClickHouse(`
SELECT operator, COUNT(*) as total
FROM aircraft_analytics
GROUP BY operator
`);
// Вставка данных
await insertClickHouse('aircraft_analytics', data);
```
---
### 4. OpenSearch - Полнотекстовый поиск
**Назначение**: Поиск по всем данным системы с поддержкой полнотекстового поиска
**Использование**:
- Поиск воздушных судов
- Поиск в документах
- Поиск в аудитах и рисках
- Нечёткий поиск (fuzzy search)
**Конфигурация**:
```env
OPENSEARCH_URL=http://localhost:9200
OPENSEARCH_USERNAME=
OPENSEARCH_PASSWORD=
```
**Файлы**:
- `lib/search/opensearch-client.ts` - клиент OpenSearch
**Использование**:
```typescript
import { searchAircraft, indexDocument } from '@/lib/search/opensearch-client';
// Индексация документа
await indexDocument('aircraft', aircraftId, aircraftData);
// Поиск
const results = await searchAircraft('RA-12345', {
operator: 'Аэрофлот',
status: 'Активен'
});
```
---
### 5. Airflow/Prefect + Python (Polars/DuckDB) - Пайплайны
**Назначение**: Оркестрация ETL пайплайнов для обработки данных
**Использование**:
- Импорт данных из ФАВТ
- Обработка больших объёмов данных
- Конвертация форматов (CSV → Parquet)
- Загрузка данных в аналитические хранилища
**Конфигурация**:
```env
PREFECT_API_URL=http://localhost:4200/api
MINIO_ENDPOINT=http://localhost:9000
CLICKHOUSE_URL=http://localhost:8123
```
**Файлы**:
- `scripts/pipelines/aircraft_pipeline.py` - пайплайн обработки ВС
- `scripts/pipelines/favt_import_pipeline.py` - пайплайн импорта ФАВТ
- `scripts/pipelines/requirements.txt` - Python зависимости
**Установка**:
```bash
pip install -r scripts/pipelines/requirements.txt
```
**Запуск пайплайна**:
```bash
# Через Prefect CLI
prefect deployment run aircraft-data-pipeline
# Или напрямую
python scripts/pipelines/aircraft_pipeline.py
```
---
### 6. OpenAPI → Генерация типов на фронт
**Назначение**: Автоматическая генерация TypeScript типов из OpenAPI схемы
**Использование**:
- Типобезопасность API
- Автодополнение в IDE
- Валидация запросов/ответов
- Документация API
**Файлы**:
- `openapi.json` - OpenAPI схема
- `scripts/openapi-generate.ts` - генератор типов
**Генерация типов**:
```bash
npm run generate:types
```
**Использование**:
```typescript
import { Aircraft, APIEndpoints } from '@/lib/api/generated-types';
// Типизированный запрос
const aircraft: Aircraft = await fetch('/api/aircraft/123').then(r => r.json());
```
---
## Docker Compose
Все сервисы запускаются через Docker Compose:
```bash
docker-compose up -d
```
**Сервисы**:
- `db` - PostgreSQL (5432)
- `minio` - MinIO S3 (9000, 9001)
- `clickhouse` - ClickHouse (8123, 9000)
- `opensearch` - OpenSearch (9200)
- `opensearch-dashboards` - Dashboards (5601)
- `redis` - Redis (6379)
- `prefect` - Prefect UI (4200)
- `backend` - FastAPI (8000)
- `frontend` - Next.js (3000)
---
## Поток данных
### Импорт данных из ФАВТ
1. **Python скрипт** (`fetch_favt_registry.py`) загружает CSV с сайта ФАВТ
2. **Prefect пайплайн** (`favt_import_pipeline.py`):
- Читает CSV
- Конвертирует в Parquet через Polars
- Сохраняет в S3/MinIO
- Импортирует в PostgreSQL
3. **Данные синхронизируются**:
- PostgreSQL - для операционных запросов
- ClickHouse - для аналитики
- OpenSearch - для поиска
### Обработка данных
1. **Пайплайн** (`aircraft_pipeline.py`):
- Загружает данные из PostgreSQL
- Обрабатывает через Polars/DuckDB
- Сохраняет в Parquet
- Загружает в S3 и ClickHouse
### Поиск
1. При создании/обновлении записи:
- Данные сохраняются в PostgreSQL
- Индексируются в OpenSearch
2. При поиске:
- Запрос идёт в OpenSearch
- Результаты возвращаются с релевантностью
---
## Переменные окружения
Создайте файл `.env.local`:
```env
# PostgreSQL
DB_HOST=localhost
DB_PORT=5432
DB_NAME=klg
DB_USER=klg
DB_PASSWORD=klg
# MinIO/S3
MINIO_ENDPOINT=http://localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=klg-data
# ClickHouse
CLICKHOUSE_URL=http://localhost:8123
CLICKHOUSE_DB=klg_analytics
CLICKHOUSE_USER=default
CLICKHOUSE_PASSWORD=
# OpenSearch
OPENSEARCH_URL=http://localhost:9200
# Prefect
PREFECT_API_URL=http://localhost:4200/api
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
```
---
## Запуск системы
1. **Запуск всех сервисов**:
```bash
docker-compose up -d
```
2. **Инициализация ClickHouse**:
```bash
docker-compose exec clickhouse clickhouse-client < scripts/clickhouse-init/init.sql
```
3. **Создание bucket в MinIO**:
- Откройте http://localhost:9001
- Логин: minioadmin / minioadmin
- Создайте bucket `klg-data`
4. **Генерация типов из OpenAPI**:
```bash
npm run generate:types
```
5. **Запуск пайплайнов**:
```bash
# Установка зависимостей
pip install -r scripts/pipelines/requirements.txt
# Запуск Prefect сервера (уже в Docker)
# Запуск пайплайна
prefect deployment run aircraft-data-pipeline
```
---
## Мониторинг
- **Prefect UI**: http://localhost:4200
- **MinIO Console**: http://localhost:9001
- **OpenSearch Dashboards**: http://localhost:5601
- **ClickHouse**: http://localhost:8123/play
---
## Дополнительная информация
См. также:
- `docs/MASHTABIRUEMOST.md` - масштабируемость
- `docs/DEPLOYMENT.md` - развёртывание
- `scripts/pipelines/` - примеры пайплайнов

View File

@ -0,0 +1,231 @@
# Внесенные изменения согласно детальному анализу
## Дата: 2024
## Обзор
Внесены изменения в проект согласно детальному анализу требований ИКАО Annex 8 и лучших практик индустрии для систем управления лётной годностью.
---
## 1. Модели данных
### 1.1. Расширена модель `Aircraft`
**Файл:** `backend/app/models/aircraft.py`
**Добавленные поля:**
- `serial_number` (String) - Серийный номер ВС
- `manufacture_date` (DateTime) - Дата производства
- `first_flight_date` (DateTime) - Дата первого полета
- `total_time` (Float) - Общий налет (TTSN - Total Time Since New) в часах
- `total_cycles` (Integer) - Общее количество циклов (TCSN - Total Cycles Since New)
- `current_status` (String) - Текущий статус (in_service, maintenance, storage, retired)
- `configuration` (Text) - Конфигурация ВС (вариант исполнения)
**Изменения:**
- `drawing_numbers` изменен с `String(1024)` на `Text` для поддержки больших объемов данных
### 1.2. Создана модель `AirworthinessCertificate`
**Файл:** `backend/app/models/airworthiness.py`
**Назначение:** Управление документами лётной годности (ДЛГ) согласно требованиям ИКАО Annex 8.
**Поля:**
- `certificate_number` - Номер сертификата (уникальный)
- `certificate_type` - Тип сертификата (standard, export, special)
- `issue_date` - Дата выдачи
- `expiry_date` - Дата истечения
- `issuing_authority` - Орган, выдавший сертификат
- `status` - Статус (valid, expired, suspended, revoked)
- `conditions` - Условия действия сертификата
- `limitations` - Ограничения
- `is_active` - Активен ли сертификат
### 1.3. Создана модель `AircraftHistory`
**Файл:** `backend/app/models/airworthiness.py`
**Назначение:** Отслеживание всех значимых событий в жизненном цикле ВС.
**Поля:**
- `event_type` - Тип события (maintenance, inspection, modification, incident, status_change)
- `event_date` - Дата события
- `description` - Описание события
- `performed_by_org_id` - Организация, выполнившая работу
- `performed_by_user_id` - Специалист, выполнивший работу
- `hours_at_event` - Налет на момент события (TTSN)
- `cycles_at_event` - Циклы на момент события (TCSN)
- `compliance_status` - Статус соответствия требованиям
- `reference_documents` - Ссылки на документы
### 1.4. Создана модель `AircraftModification`
**Файл:** `backend/app/models/modifications.py`
**Назначение:** Отслеживание обязательных и необязательных модификаций ВС (AD, SB, STC).
**Поля:**
- `modification_number` - Номер модификации (AD, SB, STC номер)
- `modification_type` - Тип модификации (AD, SB, STC, Service Bulletin)
- `title` - Название модификации
- `compliance_required` - Обязательна ли модификация
- `compliance_date` - Срок выполнения
- `compliance_status` - Статус выполнения (pending, complied, deferred, not_applicable)
- `performed_date` - Дата выполнения
- `deferral_reason` - Причина отложения выполнения
- `deferral_until` - Отложено до даты
---
## 2. Схемы Pydantic
### 2.1. Обновлена схема `Aircraft`
**Файл:** `backend/app/schemas/aircraft.py`
**Изменения:**
- Добавлены поля в `AircraftCreate`, `AircraftUpdate`, `AircraftOut`:
- `serial_number`
- `manufacture_date`
- `first_flight_date`
- `total_time`
- `total_cycles`
- `current_status`
- `configuration`
### 2.2. Создана схема `AirworthinessCertificate`
**Файл:** `backend/app/schemas/airworthiness.py`
**Схемы:**
- `AirworthinessCertificateOut` - для вывода
- `AirworthinessCertificateCreate` - для создания
- `AirworthinessCertificateUpdate` - для обновления
### 2.3. Создана схема `AircraftHistory`
**Файл:** `backend/app/schemas/airworthiness.py`
**Схемы:**
- `AircraftHistoryOut` - для вывода
- `AircraftHistoryCreate` - для создания
### 2.4. Создана схема `AircraftModification`
**Файл:** `backend/app/schemas/modifications.py`
**Схемы:**
- `AircraftModificationOut` - для вывода
- `AircraftModificationCreate` - для создания
- `AircraftModificationUpdate` - для обновления
---
## 3. API Endpoints
### 3.1. Управление ДЛГ (Airworthiness Certificate)
**Файл:** `backend/app/api/routes/airworthiness.py`
**Endpoints:**
- `GET /api/v1/airworthiness/certificates` - Список сертификатов (с фильтрацией по aircraft_id)
- `POST /api/v1/airworthiness/certificates` - Создание сертификата (admin, authority_inspector)
- `GET /api/v1/airworthiness/certificates/{cert_id}` - Получение сертификата
- `PATCH /api/v1/airworthiness/certificates/{cert_id}` - Обновление сертификата (admin, authority_inspector)
### 3.2. История ВС (Aircraft History)
**Файл:** `backend/app/api/routes/airworthiness.py`
**Endpoints:**
- `GET /api/v1/aircraft/{aircraft_id}/history` - История событий ВС (с фильтрацией по event_type)
- `POST /api/v1/aircraft/{aircraft_id}/history` - Создание записи истории (admin, operator, mro)
### 3.3. Модификации ВС (Aircraft Modifications)
**Файл:** `backend/app/api/routes/modifications.py`
**Endpoints:**
- `GET /api/v1/aircraft/{aircraft_id}/modifications` - Список модификаций ВС (с фильтрацией)
- `POST /api/v1/aircraft/{aircraft_id}/modifications` - Создание модификации (admin, operator, authority)
- `GET /api/v1/modifications/{mod_id}` - Получение модификации
- `PATCH /api/v1/modifications/{mod_id}` - Обновление модификации (admin, operator, authority)
- `GET /api/v1/modifications` - Список всех модификаций (с фильтрацией)
---
## 4. Регистрация роутеров
### 4.1. Обновлен `backend/app/api/routes/__init__.py`
Добавлены импорты:
- `airworthiness_router`
- `modifications_router`
### 4.2. Обновлен `backend/app/main.py`
Добавлена регистрация роутеров:
- `app.include_router(airworthiness.router, prefix=settings.api_v1_prefix)`
- `app.include_router(modifications.router, prefix=settings.api_v1_prefix)`
---
## 5. Обновление модели `__init__.py`
**Файл:** `backend/app/models/__init__.py`
Добавлены импорты новых моделей:
- `AirworthinessCertificate`
- `AircraftHistory`
- `AircraftModification`
---
## 6. Соответствие требованиям
### 6.1. ИКАО Annex 8
**Управление ДЛГ** - реализовано через `AirworthinessCertificate`
**Отслеживание налета** - реализовано через `total_time` и `total_cycles` в `Aircraft`
**История ВС** - реализовано через `AircraftHistory`
**Модификации** - реализовано через `AircraftModification`
### 6.2. EASA Part M (для международных операций)
**Continuing Airworthiness** - поддержка через новые модели
**Контроль выполнения обслуживания** - через `AircraftHistory`
**Отслеживание компонентов** - через существующие модели `LimitedLifeComponent`, `LandingGearComponent`
**Управление документацией** - через `reference_documents` в моделях
---
## 7. Следующие шаги
### 7.1. Миграции базы данных
Необходимо создать миграции Alembic для:
- Добавления новых полей в таблицу `aircraft`
- Создания таблиц `airworthiness_certificates`, `aircraft_history`, `aircraft_modifications`
### 7.2. Frontend компоненты
Рекомендуется создать UI компоненты для:
- Управления ДЛГ
- Просмотра истории ВС
- Управления модификациями ВС
- Отображения расширенных данных ВС
### 7.3. Дополнительные функции
- Автоматические уведомления о приближении срока истечения ДЛГ
- Автоматические уведомления о новых обязательных модификациях
- Отчеты по соответствию требованиям
- Интеграция с внешними системами для получения AD/SB
---
## Заключение
Все критические изменения согласно детальному анализу внесены. Проект теперь соответствует требованиям ИКАО Annex 8 и готов к дальнейшему развитию.

View File

@ -0,0 +1,164 @@
# Выполненные исправления согласно рекомендациям
**Дата:** 2026-01-18
---
## ✅ 1. Убрано отладочное логирование из production кода
### Изменения:
**Frontend:**
- Удалены все `console.log` и `console.error` из production кода
- Файлы обновлены:
- `frontend/src/pages/Aircraft.tsx`
- `frontend/src/pages/Dashboard.tsx`
- `frontend/src/pages/Applications.tsx`
- `frontend/src/pages/Users.tsx`
- `frontend/src/pages/Regulations.tsx`
- `frontend/src/components/DataUploadModal.tsx`
- `frontend/src/api/client.ts`
**Результат:**
- Код готов к production
- Отладочные сообщения больше не выводятся в консоль браузера
---
## ✅ 2. Добавлена централизованная обработка ошибок в backend
### Изменения:
**Создан новый файл:** `backend/app/api/exceptions.py`
Реализованы обработчики исключений:
- `validation_exception_handler` - обработка ошибок валидации Pydantic
- `integrity_error_handler` - обработка ошибок целостности БД
- `sqlalchemy_error_handler` - обработка общих ошибок SQLAlchemy
- `general_exception_handler` - обработка всех остальных исключений
**Обновлен файл:** `backend/app/main.py`
- Зарегистрированы все обработчики исключений
- Добавлено логирование ошибок
**Результат:**
- Все ошибки обрабатываются централизованно
- Пользователи получают понятные сообщения об ошибках
- Ошибки логируются для отладки
---
## ✅ 3. Внедрена система миграций БД (Alembic)
### Изменения:
**Создана структура миграций:**
- `backend/alembic.ini` - конфигурация Alembic
- `backend/alembic/env.py` - настройка окружения для миграций
- `backend/alembic/script.py.mako` - шаблон для создания миграций
- `backend/alembic/versions/` - директория для файлов миграций
**Создан файл:** `backend/README_MIGRATIONS.md`
- Инструкция по использованию миграций
- Примеры команд для создания и применения миграций
**Результат:**
- Система миграций настроена и готова к использованию
- Можно версионировать изменения схемы БД
- Поддержка отката миграций
**Использование:**
```bash
# Создать миграцию
docker compose exec backend alembic revision --autogenerate -m "Описание"
# Применить миграции
docker compose exec backend alembic upgrade head
# Откатить миграцию
docker compose exec backend alembic downgrade -1
```
---
## ✅ 4. Улучшена обработка ошибок в frontend
### Изменения:
**Создан новый файл:** `frontend/src/utils/errorHandler.ts`
Реализованы функции:
- `getErrorMessage()` - извлечение понятного сообщения об ошибке
- `isNetworkError()` - проверка сетевых ошибок
- `isAuthError()` - проверка ошибок авторизации
- `logError()` - централизованное логирование ошибок
**Обновлены файлы:**
- `frontend/src/api/client.ts` - добавлен interceptor для обработки ошибок
- `frontend/src/pages/Aircraft.tsx` - использование `getErrorMessage()`
- `frontend/src/pages/Dashboard.tsx` - использование `getErrorMessage()`
- `frontend/src/pages/Applications.tsx` - использование `getErrorMessage()`
**Результат:**
- Единообразная обработка ошибок во всем приложении
- Пользователи получают понятные сообщения об ошибках
- Готовность к интеграции с системами мониторинга (Sentry, LogRocket)
---
## 📊 Итоговая статистика
### Измененные файлы:
**Backend:**
- ✅ `backend/app/main.py` - добавлена обработка исключений
- ✅ `backend/app/api/exceptions.py` - новый файл
- ✅ `backend/alembic.ini` - новый файл
- ✅ `backend/alembic/env.py` - новый файл
- ✅ `backend/alembic/script.py.mako` - новый файл
- ✅ `backend/README_MIGRATIONS.md` - новый файл
**Frontend:**
- ✅ `frontend/src/utils/errorHandler.ts` - новый файл
- ✅ `frontend/src/api/client.ts` - обновлен
- ✅ `frontend/src/pages/Aircraft.tsx` - обновлен
- ✅ `frontend/src/pages/Dashboard.tsx` - обновлен
- ✅ `frontend/src/pages/Applications.tsx` - обновлен
- ✅ `frontend/src/pages/Users.tsx` - обновлен
- ✅ `frontend/src/pages/Regulations.tsx` - обновлен
- ✅ `frontend/src/components/DataUploadModal.tsx` - обновлен
**Всего:** 15 файлов изменено/создано
---
## 🎯 Достигнутые цели
1. ✅ **Код готов к production** - убрано отладочное логирование
2. ✅ **Улучшена обработка ошибок** - централизованная обработка в backend и frontend
3. ✅ **Внедрена система миграций** - Alembic настроен и готов к использованию
4. ✅ **Улучшен пользовательский опыт** - понятные сообщения об ошибках
---
## 📝 Следующие шаги (опционально)
1. **Мониторинг и алертинг:**
- Интегрировать Sentry или аналогичную систему
- Настроить алерты при критических ошибках
2. **Тестирование:**
- Добавить unit тесты
- Добавить integration тесты
3. **Автоматическое применение миграций:**
- Добавить автоматическое применение миграций при старте приложения
- Настроить CI/CD для проверки миграций
4. **Документация:**
- Обновить документацию API с примерами обработки ошибок
- Добавить руководство по созданию миграций
---
**Статус:** ✅ Все рекомендации выполнены

View File

@ -0,0 +1,535 @@
# Демонстрация проекта: Система контроля лётной годности (КЛГ)
## Обзор проекта
Система контроля лётной годности воздушных судов — это комплексное веб-приложение для управления данными о воздушных судах, организациях, сертификатах и документации в соответствии с требованиями ИКАО, МАК, EASA и Росавиации.
**Заказчик:** АО «REFLY»
**Вариант развертывания:** В составе АСУ ТК
**Статус:** Прототип (MVP)
---
## 🚀 Быстрый старт
### Требования
- Docker и Docker Compose
- 4 ГБ свободной оперативной памяти
- Порты 8080 (frontend) и 8000 (backend) должны быть свободны
### Запуск проекта
```bash
# Клонирование репозитория (если необходимо)
cd klg_asutk_app
# Запуск всех сервисов
docker compose up --build
# Или в фоновом режиме
docker compose up -d --build
```
### Доступ к приложению
После запуска доступны следующие адреса:
- **Frontend (веб-интерфейс):** http://localhost:8080
- **Backend API:** http://localhost:8000
- **API документация (Swagger):** http://localhost:8000/docs
- **API документация (ReDoc):** http://localhost:8000/redoc
---
## 📋 Основные функции системы
### 1. Дашборд (Главная страница)
**URL:** http://localhost:8080/
**Функциональность:**
- Отображение ключевых показателей (KPI):
- Количество организаций
- Количество воздушных судов
- Количество заявок на сертификацию
- Количество одобренных заявок
- Количество пользователей
- ВС в эксплуатации
- ВС на обслуживании
- Сертификаты с истекающим сроком действия
- Поиск по воздушным судам и организациям
- Быстрый доступ к функциям:
- **Загрузка данных** — загрузка файлов (текст, PDF, XLS, PNG) для воздушных судов
- **Документы** — переход к нормативным документам
**Дизайн:**
- Небесный градиентный фон с анимацией облаков
- Белые карточки с тенями для лучшей читаемости
- Крупные зеленые кнопки для основных действий
- Глянцевые кнопки для дополнительных функций
### 2. Воздушные суда
**URL:** http://localhost:8080/aircraft
**Функциональность:**
- Просмотр списка всех воздушных судов с фильтрацией
- Отображение информации:
- Регистрационный номер
- Серийный номер
- Тип ВС (производитель и модель)
- Оператор
- Статус (в эксплуатации, на обслуживании, на хранении, списан)
- Налет (часы)
- Количество циклов
- Чертежные номера
- Дата выполненных работ
- Создание нового воздушного судна
- Редактирование существующих записей
- Просмотр детальной информации о ВС
**Типы воздушных судов:**
- Самолеты: Boeing 737, Airbus A320, Sukhoi Superjet 100, MC-21-300, Ил-96-300, Ту-204, Ан-148 и др.
- Вертолеты: Ми-8, Ми-26, Ка-32, Ка-226, AW139, EC225 и др.
### 3. Организации
**URL:** http://localhost:8080/organizations
**Функциональность:**
- Управление организациями трех типов:
- **Операторы** — авиакомпании и эксплуатанты
- **MRO** — организации технического обслуживания
- **Органы власти** — регулирующие органы
- Создание, редактирование и удаление организаций
- Просмотр списка всех организаций с фильтрацией
**Примеры организаций в системе:**
- Аэрофлот
- S7 Airlines
- Уральские авиалинии
- Авиакомпания "Якутия"
- Авиакомпания "Победа"
- И другие
### 4. Заявки на сертификацию
**URL:** http://localhost:8080/applications
**Функциональность:**
- Просмотр заявок на сертификацию организаций по техническому обслуживанию
- Отслеживание статусов:
- `draft` — черновик
- `submitted` — подана
- `under_review` — на рассмотрении
- `remarks` — есть замечания
- `approved` — одобрена
- `rejected` — отклонена
- `expired` — истекла
- Автоматическая нумерация заявок
- Система замечаний и уведомлений
- Автоматический таймер (30 дней на исправление замечаний)
### 5. Пользователи
**URL:** http://localhost:8080/users
**Функциональность:**
- Просмотр списка пользователей системы
- Фильтрация по:
- Организации
- Роли
**Роли пользователей:**
- **Администратор** — полный доступ
- **Инспектор органа** — проверка и одобрение заявок
- **Менеджер оператора** — управление ВС оператора
- **Пользователь оператора** — просмотр данных оператора
- **Менеджер MRO** — управление данными MRO
- **Пользователь MRO** — просмотр данных MRO
### 6. Нормативные документы
**URL:** http://localhost:8080/regulations
**Функциональность:**
- Просмотр нормативных документов от:
- **ИКАО (ICAO)** — международные стандарты
- **МАК** — Межгосударственный авиационный комитет
- **EASA** — Европейское агентство авиационной безопасности
- **Росавиация** — Федеральное агентство воздушного транспорта
- Информация о каждом документе:
- Название и номер
- Тип документа
- Дата публикации/обновления
- Статус (действует, обновлен, новый)
- Категория
- Описание
- Ссылка на официальный сайт
**Примеры документов:**
- ИКАО Annex 8 — Воздушная годность воздушных судов
- ИКАО Doc 9760 — Руководство по поддержанию летной годности
- EASA Part M — Continuing Airworthiness
- EASA Part 145 — Maintenance Organisation Approvals
- ФАП-128 — Поддержание летной годности воздушных судов
---
## 🎨 Дизайн и пользовательский интерфейс
### Цветовая схема
**Фон страниц:**
- Небесный градиент: от небесно-голубого (#87CEEB) вверху до белого (#FFFFFF) внизу
- Анимированные облака для визуального эффекта
**Карточки и элементы:**
- Белый фон для карточек и таблиц
- Тени для создания глубины
- Синие акценты для интерактивных элементов
**Кнопки:**
- **Основные действия:** Крупные круглые зеленые кнопки с белым текстом
- **Дополнительные действия:** Глянцевые прямоугольные кнопки различных цветов (бирюзовый, фиолетовый, синий, оранжевый)
### Типографика
- **Заголовки:** Шрифт Unbounded (Bold, Regular)
- **Основной текст:** Шрифт Montserrat (Regular, Medium, Bold)
### Адаптивность
- Интерфейс адаптирован для различных размеров экранов
- Таблицы с горизонтальной прокруткой на мобильных устройствах
---
## 🔧 Технические детали
### Backend
**Технологии:**
- **FastAPI** — веб-фреймворк
- **SQLAlchemy** — ORM для работы с БД
- **PostgreSQL** — база данных
- **APScheduler** — планировщик задач
- **Pydantic** — валидация данных
**Основные модули:**
- `app/api/routes/` — API endpoints
- `app/models/` — модели данных SQLAlchemy
- `app/schemas/` — Pydantic схемы
- `app/services/` — бизнес-логика
- `app/db/` — работа с базой данных
- `app/integration/` — интеграции (П-ИВ АСУ ТК)
### Frontend
**Технологии:**
- **React 18** — библиотека для UI
- **TypeScript** — типизация
- **Ant Design** — UI компоненты
- **Vite** — сборщик
- **React Router** — маршрутизация
**Структура:**
- `src/pages/` — страницы приложения
- `src/components/` — переиспользуемые компоненты
- `src/api/` — клиенты для API
- `src/assets/` — стили, шрифты, изображения
- `src/layout/` — компоненты макета
### База данных
**Основные таблицы:**
- `organizations` — организации
- `aircraft` — воздушные суда
- `aircraft_types` — типы воздушных судов
- `cert_applications` — заявки на сертификацию
- `users` — пользователи
- `attachments` — вложения (файлы)
- `notifications` — уведомления
- `airworthiness_certificates` — сертификаты летной годности
---
## 📊 Примеры данных
### Воздушные суда
Система содержит данные о различных типах воздушных судов:
**Самолеты:**
- Boeing 737-800 (Аэрофлот, S7 Airlines)
- Airbus A320 (Аэрофлот, Победа)
- Sukhoi Superjet 100 (Аэрофлот, Якутия)
- MC-21-300 (Аэрофлот)
- Ил-96-300 (Аэрофлот)
- Ту-204 (Уральские авиалинии)
- Ан-148 (Якутия)
**Вертолеты:**
- Ми-8 (различные операторы)
- Ми-26
- Ка-32
- Ка-226
- AW139
- EC225
### Организации
**Операторы:**
- Аэрофлот — Российские авиалинии
- S7 Airlines
- Уральские авиалинии
- Авиакомпания "Якутия"
- Авиакомпания "Победа"
**MRO:**
- Авиационный технический центр
- Сервисный центр технического обслуживания
**Органы власти:**
- Росавиация
- МАК
---
## 🔐 Авторизация
### Режим разработки
В режиме разработки включена поддержка HS256 токенов (`ALLOW_HS256_DEV_TOKENS=true`).
**Минимальный набор claims для JWT токена:**
```json
{
"sub": "user-1",
"name": "Иван Иванов",
"email": "ivan@example.com",
"role": "operator_manager",
"org_id": "<ID организации>"
}
```
**Роли:**
- `admin` — администратор
- `authority_inspector` — инспектор органа
- `operator_manager` — менеджер оператора
- `operator_user` — пользователь оператора
- `mro_manager` — менеджер MRO
- `mro_user` — пользователь MRO
### Production
В production режиме используется OIDC/JWKS валидация через АСУ ТК-ИБ.
---
## 📡 API Endpoints
### Организации
- `GET /api/v1/organizations` — список организаций
- `POST /api/v1/organizations` — создание (admin/authority_inspector)
- `GET /api/v1/organizations/{id}` — детали организации
- `PATCH /api/v1/organizations/{id}` — обновление
- `DELETE /api/v1/organizations/{id}` — удаление
### Воздушные суда
- `GET /api/v1/aircraft` — список ВС
- `POST /api/v1/aircraft` — создание (operator/admin)
- `GET /api/v1/aircraft/{id}` — детали ВС
- `PATCH /api/v1/aircraft/{id}` — обновление
- `GET /api/v1/aircraft/types` — список типов ВС
### Заявки на сертификацию
- `GET /api/v1/cert-applications` — список заявок
- `POST /api/v1/cert-applications` — создание заявки
- `GET /api/v1/cert-applications/{id}` — детали заявки
- `POST /api/v1/cert-applications/{id}/submit` — подача заявки
- `POST /api/v1/cert-applications/{id}/start-review` — начало рассмотрения (authority)
- `POST /api/v1/cert-applications/{id}/remarks` — добавление замечаний (authority)
- `GET /api/v1/cert-applications/{id}/remarks` — список замечаний
- `POST /api/v1/cert-applications/{id}/approve` — одобрение (authority)
- `POST /api/v1/cert-applications/{id}/reject` — отклонение (authority)
### Вложения
- `POST /api/v1/attachments/{owner_kind}/{owner_id}` — загрузка файла
- `GET /api/v1/attachments/{owner_kind}/{owner_id}` — список вложений
- `GET /api/v1/attachments/{id}` — метаданные вложения
- `GET /api/v1/attachments/{id}/download` — скачивание файла
### Уведомления
- `GET /api/v1/notifications` — список уведомлений
### Пользователи
- `GET /api/v1/users` — список пользователей
- `GET /api/v1/users/{id}` — детали пользователя
### Летная годность
- `GET /api/v1/airworthiness/certificates` — список сертификатов
- `POST /api/v1/airworthiness/certificates` — создание сертификата
- `GET /api/v1/airworthiness/certificates/{id}` — детали сертификата
### Модификации
- `GET /api/v1/modifications` — список модификаций
- `POST /api/v1/modifications` — создание модификации
---
## 🧪 Тестирование
### Проверка работоспособности
1. **Проверка health endpoint:**
```bash
curl http://localhost:8000/api/v1/health
```
2. **Проверка frontend:**
- Откройте http://localhost:8080 в браузере
- Убедитесь, что загружается главная страница
3. **Проверка API документации:**
- Откройте http://localhost:8000/docs
- Попробуйте выполнить запросы через Swagger UI
### Тестовые сценарии
1. **Создание организации:**
- Перейдите в раздел "Организации"
- Нажмите "Создать"
- Заполните форму и сохраните
2. **Добавление воздушного судна:**
- Перейдите в раздел "Воздушные суда"
- Нажмите "Добавить ВС"
- Заполните форму и сохраните
3. **Загрузка файла:**
- На главной странице нажмите "Загрузка данных"
- Выберите воздушное судно
- Загрузите файл (текст, PDF, XLS или PNG)
4. **Поиск:**
- На главной странице введите запрос в поле поиска
- Нажмите "Найти"
- Проверьте результаты
---
## 📝 Логи и отладка
### Просмотр логов
```bash
# Логи всех сервисов
docker compose logs
# Логи backend
docker compose logs backend
# Логи frontend
docker compose logs frontend
# Логи в реальном времени
docker compose logs -f
```
### Очистка и перезапуск
```bash
# Остановка всех сервисов
docker compose down
# Остановка с удалением volumes (ОСТОРОЖНО: удалит данные БД)
docker compose down -v
# Пересборка без кэша
docker compose build --no-cache
# Полный перезапуск
docker compose down
docker compose up --build
```
---
## 🔄 Интеграции
### П-ИВ АСУ ТК
Система поддерживает интеграцию с протоколом интеграции и взаимодействия АСУ ТК:
- Модуль: `app/integration/piv.py`
- Функция `push_event()` — отправка событий
- Логирование интеграционных процессов в `ingest_job_logs`
### ЦХД АСУ ТК
База данных PostgreSQL используется как модель центрального хранилища данных АСУ ТК.
---
## 🚧 Планы развития
### Реализовано (MVP)
✅ Управление организациями
✅ Управление воздушными судами и типами
✅ Заявки на сертификацию с workflow
✅ Загрузка и управление файлами
✅ Система уведомлений
✅ Дашборд с KPI
✅ Нормативные документы
✅ Управление пользователями
### В разработке / Планируется
- [ ] Документ лётной годности (ДЛГ)
- [ ] Контрольные данные (КД)
- [ ] Модификации воздушных судов (полная реализация)
- [ ] Инспекции
- [ ] Контрольные карты программы ТО
- [ ] Отслеживание компонентов с ограниченным ресурсом (LLP, HT)
- [ ] Отчеты по ремонтам и повреждениям конструкции
- [ ] Отчеты по дефектам
- [ ] Отслеживание комплектующих изделий с ограниченным ресурсом (шасси)
- [ ] Полная ролевая модель и матрица прав
- [ ] Интеграция с П-НСИ (справочники)
- [ ] Автотесты
- [ ] Программно-методические инструкции (ПМИ)
---
## 📞 Контакты и поддержка
**Заказчик:** АО «REFLY»
Для вопросов и предложений обращайтесь к команде разработки.
---
## 📄 Лицензия
Проект разработан для АО «REFLY» согласно техническому заданию.
---
**Версия документа:** 1.0
**Дата обновления:** 2024

View File

@ -0,0 +1,109 @@
# Диагностика проблемы с отображением данных
## ✅ Текущий статус
В консоли браузера:
- ✅ Нет ошибок загрузки данных
- ⚠️ Предупреждения React Router (не критично)
- ⚠️ 404 для favicon.ico (не критично)
## 🔍 Проверка данных
### Шаг 1: Проверка через консоль браузера
Выполните в консоли браузера (не в терминале):
```javascript
// Проверка загрузки данных через API
fetch('/api/v1/aircraft', {
headers: { 'Authorization': 'Bearer dev' }
})
.then(r => {
console.log('✅ Status:', r.status);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => {
console.log('✅ Всего ВС:', data.length);
if (data.length > 0) {
const first = data[0];
console.log('✅ Первое ВС:', first.registration_number);
console.log('operator_name:', first.operator_name || '❌ ОТСУТСТВУЕТ');
console.log('serial_number:', first.serial_number || '❌ ОТСУТСТВУЕТ');
// Проверка всех ВС
const withOperator = data.filter(a => a.operator_name).length;
const withSerial = data.filter(a => a.serial_number).length;
console.log(`✅ ВС с operator_name: ${withOperator}/${data.length}`);
console.log(`✅ ВС с serial_number: ${withSerial}/${data.length}`);
// Показываем первые 3 ВС для проверки
console.log('Первые 3 ВС:');
data.slice(0, 3).forEach((a, i) => {
console.log(` ${i+1}. ${a.registration_number}: operator=${a.operator_name || 'НЕТ'}, serial=${a.serial_number || 'НЕТ'}`);
});
}
})
.catch(err => console.error('❌ Ошибка:', err));
```
### Шаг 2: Проверка Network tab
1. Откройте вкладку **Network** в DevTools
2. Обновите страницу (F5)
3. Найдите запрос `aircraft`
4. Проверьте:
- **Status**: должен быть `200 OK`
- **Response**: кликните на запрос → вкладка Response → должен быть JSON с данными
### Шаг 3: Проверка отображения в таблице
1. Перейдите на страницу "Воздушные суда"
2. Проверьте консоль - должны появиться логи (в dev режиме):
- `✅ Загружено ВС: 51`
- `✅ Первое ВС: {registration_number: "RA-12345", ...}`
3. Проверьте таблицу - должны отображаться:
- Колонка "Оператор" с названиями организаций
- Колонка "Серийный номер" с номерами
## 🐛 Если данные не отображаются
### Проблема 1: Данные приходят, но не отображаются в таблице
**Решение:**
1. Очистите кэш: `Cmd + Shift + R` (Mac) или `Ctrl + Shift + R` (Windows)
2. Проверьте консоль на ошибки рендеринга
3. Проверьте вкладку **Elements** - есть ли таблица с данными
### Проблема 2: Данные не приходят
**Решение:**
1. Проверьте Network tab - есть ли запрос к `/api/v1/aircraft`
2. Проверьте Status - должен быть `200 OK`
3. Проверьте Response - должен быть JSON массив
4. Проверьте Headers - должен быть `Authorization: Bearer dev`
### Проблема 3: Ошибка CORS
**Решение:**
1. Проверьте консоль на ошибки CORS
2. Проверьте настройки CORS в backend (должно быть `allow_origins=["*"]`)
## 📊 Ожидаемый результат
После выполнения кода в консоли должно быть:
- ✅ Status: 200
- ✅ Всего ВС: 51
- ✅ ВС с operator_name: 51/51
- ✅ ВС с serial_number: 51/51
- ✅ Первое ВС: RA-12345
- ✅ operator_name: Аэрофлот - Российские авиалинии
- ✅ serial_number: 08-001
## 🔧 Дополнительная диагностика
Если проблема сохраняется:
1. Выполните код проверки в консоли
2. Пришлите результат выполнения
3. Пришлите скриншот Network tab с запросом aircraft
4. Пришлите скриншот таблицы (если она отображается)

View File

@ -0,0 +1,196 @@
# Инструкция по демонстрации проекта
## Быстрая демонстрация (5-10 минут)
### 1. Запуск системы
```bash
cd klg_asutk_app
docker compose up --build
```
Дождитесь сообщения о готовности всех сервисов.
### 2. Открытие приложения
Откройте в браузере: **http://localhost:8080**
### 3. Демонстрация функций
#### Шаг 1: Главная страница (Дашборд)
- Покажите небесный фон с анимацией облаков
- Обратите внимание на KPI карточки:
- Количество организаций
- Количество воздушных судов
- Количество заявок
- ВС в эксплуатации / на обслуживании
- Сертификаты с истекающим сроком
- Продемонстрируйте поиск:
- Введите часть регистрационного номера (например, "RA-")
- Нажмите "Найти"
- Покажите результаты поиска
- Покажите кнопки "Загрузка данных" и "Документы"
#### Шаг 2: Воздушные суда
- Перейдите в раздел "Воздушные суда"
- Покажите список ВС с различными типами (самолеты и вертолеты)
- Обратите внимание на:
- Разные статусы (в эксплуатации, на обслуживании и т.д.)
- Налет и циклы
- Операторов
- Нажмите "Детали" на любом ВС, чтобы показать модальное окно с полной информацией
- Покажите возможность создания нового ВС (кнопка "Добавить ВС")
#### Шаг 3: Организации
- Перейдите в раздел "Организации"
- Покажите список организаций разных типов:
- Операторы (авиакомпании)
- MRO (организации ТО)
- Органы власти
- Продемонстрируйте создание новой организации:
- Нажмите "Создать"
- Заполните форму
- Сохраните
#### Шаг 4: Загрузка данных
- Вернитесь на главную страницу
- Нажмите кнопку "Загрузка данных"
- Покажите модальное окно:
- Выбор воздушного судна
- Выбор типа файла (текст, PDF, XLS, PNG)
- Загрузка файла
- Таблица загруженных файлов
#### Шаг 5: Нормативные документы
- Нажмите кнопку "Документы" на главной странице или перейдите через меню
- Покажите документы от разных организаций:
- ИКАО
- МАК
- EASA
- Росавиация
- Обратите внимание на:
- Статусы документов (действует, обновлен, новый)
- Категории
- Ссылки на официальные сайты
#### Шаг 6: Пользователи
- Перейдите в раздел "Пользователи"
- Покажите список пользователей с ролями
- Продемонстрируйте фильтрацию:
- По организации
- По роли
#### Шаг 7: Заявки на сертификацию
- Перейдите в раздел "Заявки"
- Покажите список заявок с различными статусами
- Обратите внимание на workflow заявок
### 4. Демонстрация API
Откройте в браузере: **http://localhost:8000/docs**
- Покажите Swagger UI
- Выполните несколько запросов:
- `GET /api/v1/organizations` — список организаций
- `GET /api/v1/aircraft` — список ВС
- `GET /api/v1/aircraft/types` — типы ВС
---
## Расширенная демонстрация (15-20 минут)
### Дополнительные сценарии
#### Сценарий 1: Полный цикл работы с ВС
1. Создайте новое воздушное судно
2. Загрузите для него документы через "Загрузка данных"
3. Просмотрите детали ВС
4. Измените статус ВС
#### Сценарий 2: Работа с организациями
1. Создайте новую организацию (оператор)
2. Создайте ВС и привяжите его к этой организации
3. Покажите фильтрацию ВС по оператору
#### Сценарий 3: Поиск и фильтрация
1. Используйте поиск на главной странице
2. Покажите фильтрацию пользователей по организации и роли
3. Продемонстрируйте сортировку в таблицах
---
## Ключевые моменты для демонстрации
### Дизайн
- ✅ Небесный фон на всех страницах
- ✅ Белые карточки с тенями для читаемости
- ✅ Крупные зеленые кнопки для основных действий
- ✅ Глянцевые кнопки для дополнительных функций
- ✅ Адаптивный дизайн
### Функциональность
- ✅ Управление воздушными судами (самолеты и вертолеты)
- ✅ Управление организациями
- ✅ Загрузка и управление файлами
- ✅ Поиск по ВС и организациям
- ✅ KPI на дашборде
- ✅ Нормативные документы
### Технические особенности
- ✅ REST API с документацией
- ✅ Docker контейнеризация
- ✅ PostgreSQL база данных
- ✅ React + TypeScript frontend
- ✅ FastAPI backend
---
## Возможные вопросы и ответы
**Q: Какие данные уже есть в системе?**
A: Система содержит тестовые данные:
- Несколько организаций (операторы, MRO, органы власти)
- Множество воздушных судов различных типов (самолеты и вертолеты)
- Типы воздушных судов (Boeing, Airbus, Sukhoi, Ми, Ка и др.)
**Q: Можно ли добавить свои данные?**
A: Да, через веб-интерфейс можно создавать новые организации, воздушные суда и загружать файлы.
**Q: Как работает авторизация?**
A: В режиме разработки используется упрощенная авторизация через JWT токены. В production будет использоваться OIDC через АСУ ТК-ИБ.
**Q: Где хранятся загруженные файлы?**
A: В текущей версии файлы хранятся локально в файловой системе. В production будет использоваться централизованное хранилище АСУ ТК.
**Q: Можно ли экспортировать данные?**
A: В текущей версии экспорт не реализован, но это можно добавить через API.
---
## Устранение проблем
### Проблема: Страница не загружается
**Решение:**
1. Проверьте, что все контейнеры запущены: `docker compose ps`
2. Проверьте логи: `docker compose logs frontend`
3. Перезапустите: `docker compose restart frontend`
### Проблема: API не отвечает
**Решение:**
1. Проверьте логи backend: `docker compose logs backend`
2. Проверьте подключение к БД: `docker compose logs db`
3. Перезапустите: `docker compose restart backend`
### Проблема: Данные не отображаются
**Решение:**
1. Проверьте, что БД инициализирована
2. Проверьте логи backend на наличие ошибок
3. Попробуйте пересоздать БД: `docker compose down -v && docker compose up --build`
---
## Заключение
Система готова к демонстрации. Все основные функции работают, интерфейс интуитивно понятен, дизайн современный и привлекательный.
Для дополнительной информации см. файл ЕМОНСТРАЦИЯ_ПРОЕКТА.md`.

View File

@ -0,0 +1,128 @@
# Инструкция по проверке реализованных изменений
## Что было реализовано согласно требованиям ИКАО
### 1. Документ лётной годности (ДЛГ)
- ✅ Модель данных `AirworthinessCertificate`
- ✅ API endpoints для управления ДЛГ
- ✅ UI компонент для просмотра и создания ДЛГ
### 2. История ВС
- ✅ Модель данных `AircraftHistory`
- ✅ API endpoints для истории событий
- ✅ UI компонент для просмотра истории
### 3. Модификации ВС
- ✅ Модель данных `AircraftModification`
- ✅ API endpoints для управления модификациями
- ✅ UI компонент для просмотра модификаций
### 4. Расширенная модель Aircraft
- ✅ Серийный номер, даты, счетчики, статус, конфигурация
---
## Как проверить изменения
### Шаг 1: Откройте приложение
1. Откройте браузер
2. Перейдите на `http://localhost:8080`
3. Войдите в систему
### Шаг 2: Проверьте страницу "ВС и типы"
1. Перейдите на страницу **"ВС и типы"** (`/aircraft`)
2. **ВАЖНО:** Сделайте **жесткую перезагрузку страницы**:
- Windows/Linux: `Ctrl + Shift + R` или `Ctrl + F5`
- Mac: `Cmd + Shift + R`
### Шаг 3: Проверьте таблицу ВС
В таблице должны быть **новые колонки**:
- ✅ **Серийный номер** (может быть пустым для старых записей)
- ✅ **Статус** (с цветовой индикацией: зеленый/оранжевый/синий/красный)
- ✅ **Налет (ч)** - общий налет в часах
- ✅ **Циклы** - общее количество циклов
### Шаг 4: Проверьте кнопку "Детали"
1. В колонке **"Действия"** должна быть кнопка **"Детали"** (с иконкой глаза 👁️)
2. Нажмите на кнопку **"Детали"** у любого ВС
3. Должно открыться модальное окно **"Детали ВС: [номер]"**
### Шаг 5: Проверьте модальное окно
В модальном окне должны быть **3 вкладки**:
1. **"Документы лётной годности"**
- Таблица с сертификатами (если есть)
- Кнопка **"Добавить ДЛГ"** вверху справа
2. **"История"**
- Таблица с историей событий ВС
- Типы событий: Обслуживание, Инспекция, Модификация, Инцидент
3. **"Модификации"**
- Таблица с модификациями ВС
- Типы: AD, SB, STC
- Статусы выполнения
### Шаг 6: Проверьте форму редактирования ВС
1. Нажмите кнопку **"Редактировать"** у любого ВС
2. В форме должны быть **новые поля**:
- ✅ Серийный номер ВС
- ✅ Дата производства
- ✅ Дата первого полета
- ✅ Общий налет (часы)
- ✅ Общее количество циклов
- ✅ Текущий статус (выпадающий список)
- ✅ Конфигурация ВС
---
## Если изменения не видны
### Решение 1: Жесткая перезагрузка
1. Откройте DevTools (F12)
2. Правой кнопкой мыши на кнопку обновления страницы
3. Выберите **"Очистить кеш и жесткая перезагрузка"**
### Решение 2: Проверка консоли браузера
1. Откройте DevTools (F12)
2. Перейдите на вкладку **Console**
3. Проверьте наличие ошибок (красные сообщения)
4. Если есть ошибки - скопируйте их и сообщите разработчику
### Решение 3: Проверка загрузки файлов
1. Откройте DevTools (F12)
2. Перейдите на вкладку **Network**
3. Обновите страницу
4. Проверьте, что файлы `AircraftDetailModal.js` и `airworthiness.js` загружаются
### Решение 4: Перезапуск контейнеров
```bash
cd /Users/yrippertgmail.com/Downloads/klg_asutk_app
docker compose restart frontend
```
---
## Проверка через API
Можно проверить, что API работает, через curl:
```bash
# Проверка ДЛГ
curl http://localhost:8000/api/v1/airworthiness/certificates -H "Authorization: Bearer dev"
# Проверка истории
curl http://localhost:8000/api/v1/aircraft/{aircraft_id}/history -H "Authorization: Bearer dev"
# Проверка модификаций
curl http://localhost:8000/api/v1/aircraft/{aircraft_id}/modifications -H "Authorization: Bearer dev"
```
---
## Контакты для поддержки
Если изменения все еще не видны после выполнения всех шагов, проверьте:
1. Логи frontend: `docker compose logs frontend`
2. Логи backend: `docker compose logs backend`
3. Консоль браузера (F12 → Console)

View File

@ -0,0 +1,111 @@
# Инструкция по проверке консоли браузера
## ✅ Что видно в вашей консоли:
1. **Предупреждения React Router** (желтые) - не критично
- Это предупреждения о будущих изменениях в React Router v7
- Не влияют на работу приложения
- Можно игнорировать
2. **Нет красных ошибок** - это хорошо!
## 🔍 Дальнейшая проверка:
### Шаг 1: Проверьте вкладку Network
1. В консоли разработчика перейдите на вкладку **Network** (Сеть)
2. Обновите страницу (F5 или Cmd+R)
3. Найдите запросы:
- `GET /api/v1/aircraft` - для загрузки ВС
- `GET /api/v1/users` - для загрузки пользователей
4. Для каждого запроса проверьте:
- **Status**: должен быть `200 OK` (зеленый)
- **Response**: нажмите на запрос → вкладка Response → проверьте, что данные есть
### Шаг 2: Проверьте данные в Response
Для запроса `/api/v1/aircraft` в Response должно быть:
```json
[
{
"registration_number": "RA-12345",
"operator_name": "Аэрофлот - Российские авиалинии",
"serial_number": "08-001",
...
}
]
```
### Шаг 3: Проверьте вкладку Application/Storage
1. Перейдите на вкладку **Application** (Chrome) или **Storage** (Firefox)
2. Проверьте **Local Storage**`http://localhost:8080`
3. Должен быть ключ `token` со значением `dev`
## 🐛 Если данные не отображаются:
### Проверка 1: Данные приходят, но не отображаются
В консоли выполните:
```javascript
// Проверка загруженных данных
fetch('/api/v1/aircraft', {
headers: { 'Authorization': 'Bearer dev' }
})
.then(r => r.json())
.then(data => {
console.log('Данные ВС:', data);
console.log('Первое ВС:', data[0]);
console.log('operator_name:', data[0]?.operator_name);
console.log('serial_number:', data[0]?.serial_number);
});
```
### Проверка 2: Проверка состояния React компонента
В консоли выполните (если есть доступ к React DevTools):
```javascript
// Проверка состояния компонента
// (требует установки React DevTools)
```
### Проверка 3: Очистка кэша
1. Откройте DevTools (F12)
2. Правой кнопкой на кнопку обновления страницы
3. Выберите "Очистить кэш и жесткая перезагрузка"
Или в терминале:
```bash
# Очистка кэша Docker
docker compose down
docker compose up -d
```
## 📊 Ожидаемые результаты:
**API работает**: Status 200, данные в Response
**Нет ошибок**: Нет красных сообщений в консоли
**Данные загружаются**: Видны запросы к API
**Токен есть**: В Local Storage есть `token`
## ❌ Возможные проблемы:
1. **CORS ошибка**: `Access-Control-Allow-Origin`
- Решение: Проверьте настройки CORS в backend
2. **401 Unauthorized**: Не авторизован
- Решение: Проверьте токен в Local Storage
3. **404 Not Found**: API не найден
- Решение: Проверьте, что backend запущен
4. **Данные пустые**: `[]` в Response
- Решение: Проверьте базу данных
## 🎯 Следующие шаги:
1. Проверьте вкладку Network
2. Проверьте Response для запросов к API
3. Пришлите скриншот вкладки Network или текст ошибок (если есть)

View File

@ -0,0 +1,255 @@
# Как запустить программу
## Lawer R в своём окне (DMG или из исходников)
**Готовый образ для macOS:** `dist/Lawer R.dmg`. Перетащите «Lawer R» в «Программы» и запускайте. Своё окно, без Docker, Python, Node и других программ.
**Если .app не открывается** (macOS: «Разработчик не опознан»): нажмите по «Lawer R» правой кнопкой → **Открыть** (один раз).
### Сборка DMG (на машине сборки: Python 3, Node.js)
```bash
pip install -r requirements-standalone.txt
./build_dmg.sh
```
Результат: `dist/Lawer R.dmg`. Данные приложения: `~/Library/Application Support/LawerR/`.
### Запуск из исходников в одном окне (без сборки .app)
Если DMG не собран или .app не запускается — можно поднять Lawer R в одном окне из кода (нужны Python 3.113.13 и Node.js). **Из корня проекта** (папка `klg_asutk_app`):
```bash
cd ~/Downloads/klg_asutk_app # или путь к папке проекта
chmod +x run-standalone-from-source.sh
./run-standalone-from-source.sh
```
Скрипт соберёт frontend (если ещё нет `frontend/dist`), поставит зависимости (в т.ч. pywebview) и запустит лаунчер. Вход: токен **`dev`**.
### Не запускается?
Откройте **`~/Library/Logs/LawerR/launcher.log`** — в нём причина и полный traceback при ошибках сервера или окна. Если порт **18473** занят, закройте другое приложение или пересоберите .app.
---
## Нет Docker?
- **Установить Docker:** [Docker Desktop для Mac](https://www.docker.com/products/docker-desktop/) — раздел 1.
- **Без Docker (проще всего):** только **Python 3.11+** и **Node.js** — один скрипт, **PostgreSQL и Homebrew не нужны**. См. **раздел 4 (вариант А)**.
- **Без Docker с PostgreSQL:** раздел 4, вариант Б (пункты 4.14.3).
---
## 1. Запуск через Docker Compose (рекомендуется)
Из корня проекта:
```bash
cd klg_asutk_app
docker compose up --build
```
После старта:
| Сервис | URL |
|----------|-----|
| **Frontend** | http://localhost:8080 |
| **API** | http://localhost:8000 |
| **Документация API (Swagger)** | http://localhost:8000/docs |
| **Health** | http://localhost:8000/api/v1/health |
### Пересборка после изменений
```bash
docker compose up --build
```
### Остановка
```bash
docker compose down
```
---
## 2. Авторизация (dev)
В dev используется заголовок:
```
Authorization: Bearer dev
```
или JWT с claim'ами: `sub`, `name`, `email`, `role`, `org_id`.
Роли: `admin`, `operator_manager`, `authority_inspector` и др.
---
## 3. Модуль юридических документов
### Юрисдикции (справочник)
После первого запуска заполните юрисдикции. **Команда выполняется только при запущенном backend** (если контейнеры остановлены — сначала `docker compose up -d` или `docker compose up --build`):
```bash
# 1) Убедитесь, что сервисы запущены (в одном терминале: docker compose up --build или в фоне: docker compose up -d)
# 2) Затем:
docker compose exec backend python -m app.db.seed_legal
```
Если видите ошибку `service "backend" is not running` — поднимите сервисы: `docker compose up -d`.
### ИИ-агенты (опционально)
Для работы классификации, проверки норм, перекрёстных ссылок и т.п. задайте в `docker-compose.yml` для сервиса `backend`:
```yaml
environment:
OPENAI_API_KEY: "sk-..."
# OPENAI_BASE_URL: "https://..." # для локальных моделей
# LEGAL_LLM_MODEL: "gpt-4o-mini" # модель по умолчанию
```
и перезапустите:
```bash
docker compose up --build -d
```
### Эндпоинты модуля legal
- `GET /api/v1/legal/jurisdictions` — список юрисдикций
- `GET /api/v1/legal/documents` — список документов
- `POST /api/v1/legal/analyze` — запуск ИИ-анализа (тело: `jurisdiction_id`, `title`, `content` и др.)
- и другие — см. http://localhost:8000/docs#/legal
---
## 4. Запуск без Docker
### Вариант А: один скрипт (без PostgreSQL и Homebrew)
Нужны только **Python 3.11+** и **Node.js** ([nodejs.org](https://nodejs.org/)).
```bash
chmod +x run-without-docker.sh # один раз, если «Permission denied»
./run-without-docker.sh
```
Скрипт: SQLite (`backend/klg.db`), каталог загрузок `backend/data/files`, зависимости из `backend/requirements-sqlite.txt` (без PostgreSQL), backend + frontend.
После запуска: **http://localhost:3000**, токен **`dev`**. Остановка: **Ctrl+C**.
---
### Вариант Б: вручную с PostgreSQL
Требуются: PostgreSQL, Python 3.11+, Node.js. Если нет Homebrew: [Docker Desktop](https://www.docker.com/products/docker-desktop/) (раздел 1) или [Homebrew](https://brew.sh) и [Postgres.app](https://postgresapp.com/).
#### 4.1. PostgreSQL
**macOS (Homebrew):** если Homebrew уже есть и PostgreSQL ещё не установлен:
```bash
brew install postgresql@16
brew services start postgresql@16
# при необходимости: export PATH="/opt/homebrew/opt/postgresql@16/bin:$PATH"
```
Создайте пользователя `klg` с паролем `klg` и БД `klg`:
```bash
# один раз: пользователь klg, пароль klg, БД klg
createuser -s klg 2>/dev/null || true
psql -d postgres -c "ALTER USER klg WITH PASSWORD 'klg';" 2>/dev/null || true
createdb -O klg klg 2>/dev/null || true
```
Если `createuser` или `psql` выдают «role klg already exists» / «database klg already exists» — это нормально.
Если `psql` или `createuser` не в PATH (Apple Silicon / Homebrew):
```bash
export PATH="/opt/homebrew/opt/postgresql@16/bin:$PATH"
# или: /usr/local/opt/postgresql@16/bin — для Intel Mac
```
Либо создайте БД под своим пользователем и укажите в `DATABASE_URL`, например:
```text
postgresql+psycopg2://ВАШ_ПОЛЬЗОВАТЕЛЬ@localhost:5432/klg
```
### 4.2. Backend
```bash
cd backend
# виртуальное окружение (по желанию)
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# зависимости
pip install -r requirements.txt
# переменные (или .env в backend/)
export DATABASE_URL="postgresql+psycopg2://klg:klg@localhost:5432/klg"
export OPENAI_API_KEY="sk-..." # по желанию
# создание таблиц и запуск
python -m app.db.init_db
python -m app.db.seed_legal # юрисдикции для legal
uvicorn app.main:app --host 0.0.0.0 --port 8000
```
API: http://localhost:8000, docs: http://localhost:8000/docs
### 4.3. Frontend
```bash
cd frontend
npm install
npm run dev
```
Фронт: http://localhost:3000. Прокси `/api``http://localhost:8000``vite.config.ts`).
---
## 5. Проверка работы
1. **Health:**
`curl http://localhost:8000/api/v1/health`
2. **Список юрисдикций (с авторизацией):**
`curl -H "Authorization: Bearer dev" http://localhost:8000/api/v1/legal/jurisdictions`
3. **Swagger:**
Откройте http://localhost:8000/docs, нажмите «Authorize», введите `dev` в качестве Bearer-токена.
---
## 6. Приложение не загружается — что проверить
### Страница белая или «не загружается»
1. **Docker (если через `docker compose`):**
- Убедитесь, что Docker запущен.
- Выполните `docker compose up --build` и дождитесь сообщений о готовности frontend и backend.
- Откройте http://localhost:8080 (не 3000: снаружи порт 8080).
2. **Вход в систему:**
- Если видите форму входа — введите токен `dev` и нажмите «Войти». Без токена дальше приложение не откроется.
3. **Бэкенд не отвечает:**
- Проверьте: `curl http://localhost:8000/api/v1/health`
- Если ошибка — поднимите backend (Docker или вручную `uvicorn app.main:app --host 0.0.0.0 --port 8000`).
- При ручном запуске фронта: backend должен быть на `http://localhost:8000`, иначе запросы `/api` будут падать.
4. **Запуск вручную (без Docker):**
- Сначала backend на порту 8000, затем `cd frontend && npm run dev`. Фронт: http://localhost:3000.
- В `vite.config.ts` по умолчанию прокси `/api` идёт на `http://localhost:8000`.
5. **Консоль браузера (F12 → Console):**
Посмотрите ошибки (сеть, CORS, 404 по `/api`). Если много 404 на `/api/*` — backend не запущен или прокси указан неверно.

View File

@ -0,0 +1,69 @@
# Краткий отчет анализа проекта
**Дата:** 2026-01-18
**Статус:** ✅ **ПРОЕКТ РАБОТАЕТ СТАБИЛЬНО**
---
## ✅ Основные выводы
1. **Все сервисы работают нормально**
- Backend: ✅ Running (порт 8000)
- Frontend: ✅ Running (порт 8080)
- Database: ✅ Running (порт 5432)
2. **Нет критических ошибок**
- Backend: нет ошибок в последних 100 строках логов
- Frontend: нет ошибок компиляции
- Database: старые ошибки решены, активных ошибок нет
3. **Данные корректны**
- 51 ВС в базе, все с полными данными (100%)
- API возвращает данные корректно
- Все поля присутствуют: operator_name, serial_number, total_time, total_cycles
4. **API работает стабильно**
- Все endpoints возвращают 200 OK
- Время ответа < 100ms
- Нет ошибок при запросах
---
## ⚠️ Обнаруженные проблемы (не критичные)
1. **Старые ошибки в логах БД** (решено)
- Ошибки от 13:57-14:03 связаны с отсутствием колонок
- Колонки добавлены, ошибки больше не возникают
2. **Пропущенное задание планировщика** (не критично)
- Задание было пропущено из-за перезапуска сервера
- Не влияет на работу системы
3. **Логирование через console.log** (требует улучшения)
- В production коде есть отладочные console.log
- Рекомендуется заменить на систему логирования
---
## 📊 Статистика
- **ВС в базе:** 51
- **ВС с полными данными:** 51 (100%)
- **API запросов (последние 200):** все успешные (200 OK)
- **Ошибок в логах:** 0 активных
---
## 🔧 Рекомендации
1. ✅ Убрать отладочное логирование из production
2. ⚠️ Внедрить систему миграций БД (Alembic)
3. ⚠️ Настроить мониторинг и алертинг
4. ⚠️ Добавить тестирование
5. ⚠️ Улучшить обработку ошибок
---
**Заключение:** Проект находится в стабильном рабочем состоянии. Все основные компоненты функционируют корректно. Обнаруженные проблемы не критичны и не влияют на работу системы.
Подробный отчет: см. `docs/АНАЛИЗРОЕКТА.md`

View File

@ -0,0 +1,97 @@
# Отчёт о проверке работоспособности (КЛГ / модуль Legal)
**Дата:** 2025
**Проверено:** приложение КЛГ (АСУ ТК), модуль юридических документов (Legal).
**Примечание:** в проекте нет компонента с названием «Lawer R» / «Lawyer R»; проверка выполнена для всего приложения и Legal-модуля.
---
## 1. Что проверено
| Область | Результат |
|---------|-----------|
| Линтер (backend, frontend) | Ошибок нет |
| Синтаксис Python (main, db, config, legal) | Ошибок нет |
| Совместимость с SQLite | Исправлена сортировка в Legal (см. ниже) |
| Зависимости для запуска без Docker | Добавлен `requirements-sqlite.txt` (без psycopg2) |
---
## 2. Внесённые исправления
### 2.1. Legal API: совместимость с SQLite
**Файл:** `backend/app/api/routes/legal.py`
**Проблема:** В `list_judicial_practices` использовалось `order_by(..., nulls_last())`. В SQLite старше 3.30 `NULLS LAST` не поддерживается и запрос мог падать.
**Исправление:** Сортировка заменена на переносимый вариант:
`order_by(decision_date.is_(None), decision_date.desc(), created_at.desc())` — строки с `decision_date = NULL` уходят в конец и при SQLite, и при PostgreSQL.
### 2.2. Запуск без Docker при отсутствии PostgreSQL
**Проблема:** `pip install -r requirements.txt` требует `psycopg2-binary`, для которого нужен `pg_config` (установленный PostgreSQL). На Mac без Homebrew/PostgreSQL установка падала.
**Исправление:**
- Добавлен `backend/requirements-sqlite.txt` без `psycopg2-binary`.
- `run-without-docker.sh` переведён на `requirements-sqlite.txt`.
Для запуска с PostgreSQL по-прежнему используется `requirements.txt`.
---
## 3. Модуль Legal (условно «Lawyer»)
### 3.1. Backend
- Роуты: юрисдикции, документы, перекрёстные ссылки, комментарии, судебная практика, ИИ-анализ (`/analyze`, `/documents/{id}/analyze`).
- Схемы и модели согласованы.
- При отсутствии `OPENAI_API_KEY` агенты работают в режиме заглушек (эвристики, без вызовов LLM), падений нет.
- `AnalysisRequest` требует `jurisdiction_id` — проверка на стороне API есть.
### 3.2. Frontend
- Отдельной страницы «Legal» / «Юридические документы» в UI нет.
- Модуль доступен через:
- Swagger: `http://localhost:8000/docs` → секция **legal**;
- `curl` или иной HTTP-клиент.
Если под «Lawer R» подразумевается юридический модуль, для полноценного использования из браузера нужна отдельная страница и вызовы `/api/v1/legal/*`.
---
## 4. Рекомендации
1. **Запуск без Docker**
Использовать `./run-without-docker.sh` (используется `requirements-sqlite.txt`).
Если `pip install` для `requirements-sqlite.txt` падает (например, на Python 3.14) — проверить доступность колес для Вашей версии Python или использовать 3.113.12.
2. **Отладочные логи в production**
В `frontend/src/api/client.ts` в ответах на `/aircraft` остаются `console.log`. Для prod их лучше отключить или вынести за флаг.
3. **Интерфейс для Legal**
Для работы с юрисдикциями, документами и ИИ-анализом через браузер имеет смысл добавить страницу «Юридические документы» с вызовами `/api/v1/legal/*`.
4. **Docker**
Поведение при `docker compose up` не менялось: по-прежнему используются `requirements.txt`, `DATABASE_URL` и `STORAGE_DIR` из `docker-compose.yml`.
---
## 5. Как перепроверить
```bash
# Без Docker (SQLite)
./run-without-docker.sh
# В браузере: http://localhost:3000, токен dev
# Legal API (после запуска backend)
curl -s -H "Authorization: Bearer dev" http://localhost:8000/api/v1/legal/jurisdictions
curl -s http://localhost:8000/api/v1/health
```
---
## 6. Итог
- Критичных ошибок в коде и конфигурации не найдено.
- Устранены: несовместимость Legal с SQLite и зависимость запуска без Docker от установленного PostgreSQL.
- Модуль Legal на backend реализован и пригоден к использованию через API; в веб-интерфейсе для него отдельного раздела пока нет.

View File

@ -0,0 +1,79 @@
# Инструкция: Проверка данных в браузере
## ✅ Что видно в вашем Network tab:
- Запросы к `/aircraft` выполняются успешно (Status: 200)
- Размер ответа: ~35 KB (это много данных, значит данные есть)
- Нет ошибок в Network
## 🔍 Следующий шаг: Проверьте Response
### Как проверить Response:
1. **В Network tab** найдите запрос `aircraft` (один из них)
2. **Кликните на него** - откроется панель справа
3. Перейдите на вкладку **"Response"** или **"Preview"**
4. Проверьте структуру данных
### Что должно быть в Response:
```json
[
{
"registration_number": "RA-12345",
"operator_name": "Аэрофлот - Российские авиалинии",
"serial_number": "08-001",
"aircraft_type": {
"manufacturer": "Миль",
"model": "Ми-8"
},
...
}
]
```
### Если данные есть в Response, но не отображаются в таблице:
1. **Проверьте консоль** на ошибки рендеринга
2. **Очистите кэш**: Cmd + Shift + R (Mac) или Ctrl + Shift + R (Windows)
3. **Проверьте React DevTools** (если установлен)
## 🐛 Диагностика в консоли браузера:
Выполните в консоли браузера:
```javascript
// Проверка данных через API
fetch('/api/v1/aircraft', {
headers: { 'Authorization': 'Bearer dev' }
})
.then(r => r.json())
.then(data => {
console.log('✅ Всего ВС:', data.length);
if (data.length > 0) {
const first = data[0];
console.log('✅ Первое ВС:', first.registration_number);
console.log('operator_name:', first.operator_name || '❌ ОТСУТСТВУЕТ');
console.log('serial_number:', first.serial_number || '❌ ОТСУТСТВУЕТ');
// Проверка всех ВС
const withOperator = data.filter(a => a.operator_name).length;
const withSerial = data.filter(a => a.serial_number).length;
console.log(`✅ ВС с operator_name: ${withOperator}/${data.length}`);
console.log(`✅ ВС с serial_number: ${withSerial}/${data.length}`);
}
})
.catch(err => console.error('❌ Ошибка:', err));
```
## 📊 Ожидаемый результат:
- ✅ Всего ВС: 51
- ✅ ВС с operator_name: 51/51
- ✅ ВС с serial_number: 51/51
## 🔧 Если данные не отображаются:
1. **Проверьте Response** в Network tab
2. **Выполните код выше** в консоли браузера
3. **Пришлите результат** - я помогу исправить проблему

View File

@ -0,0 +1,102 @@
# Решение проблемы "Failed to load response data" в Network tab
## 🔍 Проблема
В Network tab видна ошибка "Failed to load response data" в Preview, но API работает корректно.
## ✅ Решение
### Шаг 1: Проверьте вкладку Response (не Preview)
1. В Network tab кликните на запрос `aircraft`
2. Перейдите на вкладку **"Response"** (не "Preview")
3. Там должен быть JSON с данными
**Примечание:** Preview может не работать для больших ответов (>40KB), но Response всегда показывает данные.
### Шаг 2: Проверка в консоли браузера
Выполните в консоли браузера:
```javascript
// Проверка загрузки данных
fetch('/api/v1/aircraft', {
headers: { 'Authorization': 'Bearer dev' }
})
.then(r => {
console.log('Status:', r.status);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => {
console.log('✅ Всего ВС:', data.length);
if (data.length > 0) {
const first = data[0];
console.log('✅ Первое ВС:', first.registration_number);
console.log('operator_name:', first.operator_name || '❌ ОТСУТСТВУЕТ');
console.log('serial_number:', first.serial_number || '❌ ОТСУТСТВУЕТ');
// Проверка всех ВС
const withOperator = data.filter(a => a.operator_name).length;
const withSerial = data.filter(a => a.serial_number).length;
console.log(`✅ ВС с operator_name: ${withOperator}/${data.length}`);
console.log(`✅ ВС с serial_number: ${withSerial}/${data.length}`);
}
})
.catch(err => console.error('❌ Ошибка:', err));
```
### Шаг 3: Очистка кэша
1. Закройте DevTools
2. Выполните жесткую перезагрузку: `Cmd + Shift + R` (Mac) или `Ctrl + Shift + R` (Windows)
3. Откройте DevTools снова
4. Перейдите на страницу "Воздушные суда"
5. Проверьте консоль - должны появиться логи с данными
### Шаг 4: Проверка данных в таблице
После перезагрузки проверьте:
1. Отображается ли таблица с ВС
2. Есть ли данные в колонках "Оператор" и "Серийный номер"
3. Если данных нет - проверьте консоль на ошибки
## 🐛 Если данные все еще не отображаются
### Проверка 1: Данные приходят, но не отображаются
В консоли выполните:
```javascript
// Проверка состояния компонента (если есть React DevTools)
// Или проверка через window
```
### Проверка 2: Проблема с рендерингом
1. Откройте вкладку **Elements** в DevTools
2. Найдите таблицу с ВС
3. Проверьте, есть ли там строки с данными
### Проверка 3: Ошибки JavaScript
1. Проверьте консоль на красные ошибки
2. Проверьте вкладку **Issues** в DevTools
3. Пришлите текст ошибок
## 📊 Ожидаемый результат
После выполнения кода в консоли должно быть:
- ✅ Всего ВС: 51
- ✅ ВС с operator_name: 51/51
- ✅ ВС с serial_number: 51/51
- ✅ Первое ВС: RA-12345
- ✅ operator_name: Аэрофлот - Российские авиалинии
- ✅ serial_number: 08-001
## 🔧 Дополнительная диагностика
Если проблема сохраняется, проверьте:
1. **Headers запроса** - должен быть `Authorization: Bearer dev`
2. **Status** - должен быть `200 OK`
3. **Response** - должен быть JSON массив с данными
4. **Console** - должны быть логи с данными (в dev режиме)

View File

@ -0,0 +1,265 @@
# Рекомендации по улучшению проекта согласно требованиям ИКАО и лучшим практикам
## Анализ на основе требований ИКАО Annex 8 и практик индустрии
### Общая информация
**Основа:** Требования ИКАО Annex 8 (Airworthiness of Aircraft), EASA Part M, лучшие практики систем управления лётной годностью
---
## 1. Управление документами лётной годности (ДЛГ)
### Текущее состояние
- ❌ ДЛГ не реализован
### Рекомендации
1. **Модель данных для ДЛГ:**
```python
class AirworthinessCertificate(Base, TimestampMixin):
id: str
aircraft_id: str
certificate_number: str # Номер сертификата
certificate_type: str # Тип сертификата (стандартный, экспортный и т.д.)
issue_date: datetime
expiry_date: datetime
issuing_authority: str # Орган, выдавший сертификат
status: str # valid, expired, suspended, revoked
conditions: Text # Условия действия сертификата
limitations: Text # Ограничения
remarks: Text
```
2. **Функциональность:**
- Управление жизненным циклом сертификатов
- Автоматические уведомления о приближении срока истечения
- История изменений сертификатов
- Интеграция с процессом сертификации организаций
---
## 2. Контрольные данные (КД) и история ВС
### Текущее состояние
- ⚠️ Частично реализовано (базовые данные ВС есть)
### Рекомендации
1. **Расширенная модель истории ВС:**
```python
class AircraftHistory(Base, TimestampMixin):
id: str
aircraft_id: str
event_type: str # maintenance, inspection, modification, incident
event_date: datetime
description: Text
performed_by_org_id: str # Организация, выполнившая работу
performed_by_user_id: str # Специалист
hours_at_event: float # Налет на момент события
cycles_at_event: int # Циклы на момент события
documents: List[str] # Ссылки на документы
compliance_status: str # Соответствие требованиям
```
2. **Счетчики и метрики:**
- Автоматический расчет налета (Total Time Since New - TTSN)
- Отслеживание циклов (Total Cycles Since New - TCSN)
- Расчет остаточного ресурса компонентов
- Прогнозирование следующего обслуживания
---
## 3. Программа технического обслуживания (MPD/MRB)
### Текущее состояние
- ✅ Базовая модель MaintenanceTask реализована
### Рекомендации
1. **Расширение модели MaintenanceTask:**
- Добавить связь с типом ВС (для шаблонов)
- Группировка задач по типам обслуживания (A-check, B-check, C-check, D-check)
- Приоритеты задач
- Зависимости между задачами
- Автоматический расчет сроков на основе налета/циклов
2. **Функциональность:**
- Шаблоны программ ТО для типов ВС
- Автоматическое создание задач при достижении интервалов
- Контроль выполнения с подписями и печатями
- Интеграция с календарем обслуживания
---
## 4. Управление модификациями ВС
### Текущее состояние
- ❌ Не реализовано
### Рекомендации
1. **Модель модификаций:**
```python
class AircraftModification(Base, TimestampMixin):
id: str
aircraft_id: str
modification_number: str # Номер модификации (SB, AD, STC)
modification_type: str # SB, AD, STC, Service Bulletin
title: str
description: Text
applicable_aircraft_types: List[str] # Типы ВС, к которым применима
compliance_required: bool
compliance_date: datetime
compliance_status: str # pending, complied, deferred
compliance_method: Text # Способ выполнения
performed_date: datetime
performed_by_org_id: str
documents: List[str] # Ссылки на документы модификации
remarks: Text
```
2. **Функциональность:**
- Отслеживание обязательных модификаций (AD - Airworthiness Directives)
- Уведомления о новых модификациях
- Контроль сроков выполнения
- История выполнения модификаций
---
## 5. Инспекции и аудиты
### Текущее состояние
- ❌ Не реализовано
### Рекомендации
1. **Модель инспекций:**
```python
class Inspection(Base, TimestampMixin):
id: str
aircraft_id: str
inspection_type: str # scheduled, unscheduled, special, annual
scheduled_date: datetime
performed_date: datetime
inspector_org_id: str
inspector_user_id: str
findings: Text # Результаты инспекции
findings_count: int
compliance_status: str
corrective_actions: Text
next_inspection_date: datetime
documents: List[str]
```
2. **Функциональность:**
- Планирование инспекций
- Чек-листы инспекций
- Отчеты по результатам
- Отслеживание корректирующих действий
---
## 6. Улучшения существующих моделей
### Aircraft (Воздушные суда)
**Рекомендуемые дополнения:**
- `serial_number: str` - Серийный номер ВС
- `manufacture_date: datetime` - Дата производства
- `first_flight_date: datetime` - Дата первого полета
- `total_time: float` - Общий налет (TTSN)
- `total_cycles: int` - Общее количество циклов (TCSN)
- `current_status: str` - Текущий статус (in_service, maintenance, storage, retired)
- `airworthiness_certificate_id: str` - Связь с ДЛГ
- `configuration: Text` - Конфигурация ВС (вариант исполнения)
### MaintenanceTask
**Рекомендуемые дополнения:**
- `maintenance_type: str` - Тип обслуживания (A, B, C, D, Line, Base)
- `priority: str` - Приоритет (critical, high, normal, low)
- `estimated_duration: int` - Оценочная длительность (часы)
- `actual_duration: int` - Фактическая длительность
- `technician_signature: str` - Подпись техника
- `inspector_signature: str` - Подпись инспектора
- `compliance_evidence: List[str]` - Доказательства выполнения
---
## 7. Отчетность и аналитика
### Рекомендации
1. **Дашборды:**
- Статус лётной годности флота
- График предстоящих обслуживаний
- Статистика по типам обслуживания
- Анализ простоев ВС
- Соблюдение сроков обслуживания
2. **Отчеты:**
- Отчет о состоянии ВС (Aircraft Status Report)
- Отчет о выполненных работах (Work Performed Report)
- Отчет о дефектах (Defect Report)
- Отчет о модификациях (Modification Compliance Report)
- Отчет о компонентах с ограниченным ресурсом (LLP Report)
---
## 8. Интеграции и соответствие стандартам
### Рекомендации
1. **Интеграция с внешними системами:**
- Импорт данных из систем планирования полетов
- Интеграция с системами управления запчастями
- Синхронизация с реестрами ВС
- Интеграция с системами отчетности регуляторов
2. **Соответствие стандартам:**
- ИКАО Annex 8 (Airworthiness of Aircraft)
- EASA Part M (Continuing Airworthiness)
- FAA Part 91/135/121 (для международных операций)
- Локальные требования Росавиации
---
## 9. Безопасность и аудит
### Рекомендации
1. **Аудит изменений:**
- Логирование всех изменений критических данных
- История изменений с указанием пользователя и времени
- Возможность отката изменений
- Подписи и утверждения для критических операций
2. **Контроль доступа:**
- Детальная матрица прав доступа
- Разделение ролей (оператор, MRO, регулятор, инспектор)
- Двухфакторная аутентификация для критических операций
- Сессионное управление
---
## 10. Приоритеты внедрения
### Высокий приоритет
1. ✅ ДЛГ (Документ лётной годности) - критично для соответствия ИКАО
2. ✅ Расширение модели Aircraft (счетчики, статусы)
3. ✅ Улучшение MaintenanceTask (подписи, доказательства выполнения)
4. ✅ История ВС (AircraftHistory)
### Средний приоритет
5. ⚠️ Модификации ВС
6. ⚠️ Инспекции
7. ⚠️ Расширенная отчетность
### Низкий приоритет
8. 📋 Интеграции с внешними системами
9. 📋 Расширенная аналитика
10. 📋 Мобильное приложение для инспекторов
---
## Заключение
Проект имеет хорошую основу, но для полного соответствия требованиям ИКАО и лучшим практикам индустрии необходимо:
1. Реализовать управление ДЛГ
2. Расширить модели данных для полного жизненного цикла ВС
3. Добавить функциональность для модификаций и инспекций
4. Улучшить систему отчетности
5. Внедрить расширенный аудит и контроль доступа
Эти изменения позволят проекту соответствовать международным стандартам и требованиям регуляторов.

View File

@ -0,0 +1,196 @@
# Рекомендации по улучшению проекта на основе анализа Bureau Veritas и требований ИКАО
## Введение
На основе анализа требований ИКАО Annex 8 (Airworthiness of Aircraft), лучших практик индустрии и стандартов сертификации, ниже представлены рекомендации по улучшению проекта КЛГ.
---
## 1. КРИТИЧНЫЕ УЛУЧШЕНИЯ (Высокий приоритет)
### 1.1. Документ лётной годности (ДЛГ / Airworthiness Certificate)
**Текущее состояние:** ❌ Не реализовано
**Требования ИКАО Annex 8:**
- Каждое ВС должно иметь действующий сертификат лётной годности
- Сертификат должен содержать условия и ограничения
- Необходимо отслеживание сроков действия
**Рекомендации:**
1. Создать модель `AirworthinessCertificate`
2. Связать с моделью `Aircraft`
3. Реализовать автоматические уведомления о приближении срока истечения
4. Добавить историю изменений сертификатов
**Приоритет:** 🔴 КРИТИЧНО
---
### 1.2. Расширение модели Aircraft
**Текущее состояние:** ⚠️ Базовые поля реализованы
**Рекомендуемые дополнения:**
- Серийный номер ВС
- Дата производства
- Дата первого полета
- Общий налет (TTSN - Total Time Since New)
- Общее количество циклов (TCSN - Total Cycles Since New)
- Текущий статус ВС (в эксплуатации, на обслуживании, на хранении)
- Связь с ДЛГ
- Конфигурация ВС
**Приоритет:** 🔴 КРИТИЧНО
---
### 1.3. История ВС (Aircraft History)
**Текущее состояние:** ❌ Не реализовано
**Требования:**
- Полная история всех событий ВС
- Отслеживание налета и циклов
- История обслуживаний, модификаций, инцидентов
**Рекомендации:**
1. Создать модель `AircraftHistory`
2. Автоматическое логирование всех изменений
3. Временная шкала событий
4. Связь с документами
**Приоритет:** 🔴 КРИТИЧНО
---
## 2. ВАЖНЫЕ УЛУЧШЕНИЯ (Средний приоритет)
### 2.1. Модификации ВС (Modifications)
**Текущее состояние:** ❌ Не реализовано
**Требования ИКАО:**
- Отслеживание обязательных модификаций (AD - Airworthiness Directives)
- Контроль сроков выполнения
- Документирование выполненных модификаций
**Рекомендации:**
1. Модель `AircraftModification`
2. Интеграция с базой данных AD/SB
3. Уведомления о новых обязательных модификациях
4. Отчеты о соответствии
**Приоритет:** 🟡 ВАЖНО
---
### 2.2. Инспекции и аудиты
**Текущее состояние:** ❌ Не реализовано
**Требования:**
- Планирование периодических инспекций
- Чек-листы инспекций
- Отчеты по результатам
- Отслеживание корректирующих действий
**Рекомендации:**
1. Модель `Inspection`
2. Шаблоны чек-листов
3. Интеграция с календарем
4. Отчеты по инспекциям
**Приоритет:** 🟡 ВАЖНО
---
### 2.3. Улучшение MaintenanceTask
**Текущее состояние:** ✅ Базовая модель есть
**Рекомендации:**
- Добавить подписи техника и инспектора
- Добавить доказательства выполнения (фото, документы)
- Группировка по типам обслуживания (A, B, C, D-check)
- Приоритеты задач
- Зависимости между задачами
- Автоматический расчет сроков
**Приоритет:** 🟡 ВАЖНО
---
## 3. ДОПОЛНИТЕЛЬНЫЕ УЛУЧШЕНИЯ (Низкий приоритет)
### 3.1. Расширенная отчетность
**Рекомендации:**
- Дашборды с аналитикой
- Экспорт отчетов в PDF/Excel
- Автоматическая генерация отчетов
- Интеграция с системами BI
**Приоритет:** 🟢 ЖЕЛАТЕЛЬНО
---
### 3.2. Интеграции
**Рекомендации:**
- Импорт данных из систем планирования полетов
- Интеграция с системами управления запчастями
- Синхронизация с реестрами ВС
- API для внешних систем
**Приоритет:** 🟢 ЖЕЛАТЕЛЬНО
---
## 4. СООТВЕТСТВИЕ СТАНДАРТАМ
### 4.1. ИКАО Annex 8
**Ключевые требования:**
- ✅ Сертификация организаций (реализовано)
- ❌ Управление ДЛГ (требуется)
- ⚠️ Отслеживание налета (частично)
- ⚠️ Программа ТО (базовая реализация)
- ❌ Модификации (требуется)
- ❌ Инспекции (требуется)
### 4.2. EASA Part M (для международных операций)
**Ключевые требования:**
- Управление continuing airworthiness
- Контроль выполнения обслуживания
- Отслеживание компонентов
- Управление документацией
---
## 5. ПЛАН ВНЕДРЕНИЯ
### Фаза 1 (Критично - 1-2 месяца)
1. Расширение модели Aircraft
2. Создание модели AirworthinessCertificate
3. Создание модели AircraftHistory
4. Улучшение MaintenanceTask
### Фаза 2 (Важно - 2-3 месяца)
5. Модель AircraftModification
6. Модель Inspection
7. Расширенная отчетность
8. Улучшение дашбордов
### Фаза 3 (Желательно - 3-6 месяцев)
9. Интеграции с внешними системами
10. Мобильное приложение
11. Расширенная аналитика
12. Автоматизация процессов
---
## Заключение
Проект имеет хорошую основу, но для полного соответствия требованиям ИКАО и международным стандартам необходимо реализовать критичные компоненты, особенно управление ДЛГ и расширенную историю ВС.

View File

@ -0,0 +1,341 @@
# Справочники системы
## Общая информация
Все справочники системы заполнены тестовыми данными для полноценной работы программы.
---
## 1. Типы воздушных судов (aircraft_types)
**Всего: 34 типа от 10 производителей**
### Зарубежные производители:
#### Airbus (7 типов)
- A320
- A320neo
- A321
- A321neo
- A330-300
- A350-900
- A350-1000
#### Boeing (6 типов)
- 737-800
- 737-900
- 737 MAX 8
- 777-300ER
- 787-8 Dreamliner
- 787-9 Dreamliner
#### Embraer (4 типа)
- E-170
- E-175
- E-190
- E-195
#### Sukhoi (3 типа)
- Superjet 100
- Superjet 100-95
- Superjet 100-95LR
#### ATR (2 типа)
- 72-600
- 42-600
#### Bombardier (2 типа)
- CRJ-900
- CRJ-1000
### Российские производители:
#### Ильюшин (3 типа)
- Ил-96-300
- Ил-96-400
- Ил-114-300
#### Туполев (3 типа)
- Ту-204
- Ту-214
- Ту-334
#### Иркут (2 типа)
- МС-21-300
- МС-21-310
#### Антонов (2 типа)
- Ан-148
- Ан-158
---
## 2. Организации (organizations)
**Всего: 24 организации**
### Операторы (авиакомпании) - 9 организаций
1. **Аэрофлот - Российские авиалинии**
- ИНН: 7702070139
- Адрес: г. Москва, ул. Ленинградский проспект, д. 37, корп. 2
- Email: info@aeroflot.ru
- Телефон: +7 (495) 223-55-55
2. **S7 Airlines**
- ИНН: 5405013127
- Адрес: г. Новосибирск, ул. Добролюбова, д. 2
- Email: info@s7.ru
- Телефон: +7 (495) 777-77-77
3. **Уральские авиалинии**
- ИНН: 6658004767
- Адрес: г. Екатеринбург, ул. Сакко и Ванцетти, д. 105
- Email: info@uralairlines.ru
- Телефон: +7 (343) 264-00-00
4. **Победа**
- ИНН: 7702070139
- Адрес: г. Москва, ул. Ленинградский проспект, д. 37, корп. 2
- Email: info@pobeda.aero
- Телефон: +7 (495) 363-00-00
5. **Россия**
- ИНН: 4703008763
- Адрес: г. Санкт-Петербург, ул. Пилотов, д. 18
- Email: info@rossiya-airlines.com
- Телефон: +7 (812) 333-22-11
6. **Якутия**
- ИНН: 1435027000
- Адрес: г. Якутск, ул. Октябрьская, д. 9
- Email: info@yakutia.aero
- Телефон: +7 (4112) 44-11-11
7. **Азимут**
- ИНН: 2315086352
- Адрес: г. Ростов-на-Дону, пр-т Шолохова, д. 344
- Email: info@azimuth.aero
- Телефон: +7 (863) 206-00-00
8. **Smartavia**
- ИНН: 2901000000
- Адрес: г. Архангельск, ул. Воскресенская, д. 77
- Email: info@smartavia.ru
- Телефон: +7 (8182) 65-00-00
### MRO (организации технического обслуживания) - 8 организаций
1. **Авиационный технический центр Шереметьево**
- Адрес: г. Москва, аэропорт Шереметьево
- Email: info@atc-svo.ru
- Телефон: +7 (495) 578-00-00
2. **Авиационный технический центр Домодедово**
- Адрес: г. Москва, аэропорт Домодедово
- Email: info@atc-dme.ru
- Телефон: +7 (495) 933-00-00
3. **Авиационный технический центр Пулково**
- Адрес: г. Санкт-Петербург, аэропорт Пулково
- Email: info@atc-led.ru
- Телефон: +7 (812) 337-00-00
4. **Авиационный технический центр Толмачево**
- Адрес: г. Новосибирск, аэропорт Толмачево
- Email: info@atc-ovb.ru
- Телефон: +7 (383) 216-00-00
5. **Авиационный технический центр Кольцово**
- Адрес: г. Екатеринбург, аэропорт Кольцово
- Email: info@atc-svk.ru
- Телефон: +7 (343) 264-00-00
6. **Авиационный технический центр Ростов-на-Дону**
- Адрес: г. Ростов-на-Дону, аэропорт Платов
- Email: info@atc-rov.ru
- Телефон: +7 (863) 206-00-00
7. **Авиационный технический центр Краснодар**
- Адрес: г. Краснодар, аэропорт Пашковский
- Email: info@atc-krr.ru
- Телефон: +7 (861) 200-00-00
8. **Авиационный технический центр Сочи**
- Адрес: г. Сочи, аэропорт Сочи
- Email: info@atc-aer.ru
- Телефон: +7 (862) 240-00-00
### Органы сертификации (authority) - 4 организации
1. **Федеральное агентство воздушного транспорта (Росавиация)**
- Адрес: г. Москва, ул. Шаболовка, д. 4
- Email: info@favt.gov.ru
- Телефон: +7 (495) 607-00-00
2. **Межгосударственный авиационный комитет (МАК)**
- Адрес: г. Москва, ул. Большая Ордынка, д. 22/2
- Email: info@mak-iac.org
- Телефон: +7 (495) 607-00-00
3. **Европейское агентство по авиационной безопасности (EASA)**
- Адрес: Konrad-Adenauer-Ufer 3, 50668 Köln, Germany
- Email: info@easa.europa.eu
- Телефон: +49 221 8999 000
4. **Федеральное управление гражданской авиации США (FAA)**
- Адрес: 800 Independence Avenue SW, Washington, DC 20591, USA
- Email: info@faa.gov
- Телефон: +1 (202) 267-1000
### Другие организации - 3 организации
1. **ОАК (Объединенная авиастроительная корпорация)**
- Адрес: г. Москва, ул. Большая Дмитровка, д. 26/1
- Email: info@uacrussia.ru
- Телефон: +7 (495) 926-00-00
2. **Иркут (ПАО Корпорация Иркут)**
- Адрес: г. Иркутск, ул. Новаторов, д. 3
- Email: info@irkut.com
- Телефон: +7 (3952) 39-00-00
3. **Сухой (ПАО Компания Сухой)**
- Адрес: г. Москва, ул. Поликарпова, д. 23А
- Email: info@sukhoi.org
- Телефон: +7 (495) 926-00-00
---
## 3. Пользователи (users)
**Всего: 18 пользователей**
### Администраторы - 3 пользователя
1. **Администратор системы**
- Email: admin@klg.local
- Роль: admin
2. **Администратор Росавиации**
- Email: admin@favt.gov.ru
- Роль: admin
- Организация: Федеральное агентство воздушного транспорта (Росавиация)
### Инспекторы органов сертификации - 3 пользователя
1. **Иванов Иван Иванович**
- Email: i.ivanov@favt.gov.ru
- Роль: authority_inspector
- Организация: Федеральное агентство воздушного транспорта (Росавиация)
2. **Петров Петр Петрович**
- Email: p.petrov@favt.gov.ru
- Роль: authority_inspector
- Организация: Федеральное агентство воздушного транспорта (Росавиация)
3. **Сидоров Сидор Сидорович**
- Email: s.sidorov@favt.gov.ru
- Роль: authority_inspector
- Организация: Федеральное агентство воздушного транспорта (Росавиация)
### Менеджеры операторов - 3 пользователя
1. **Смирнов Алексей Владимирович** (Аэрофлот)
- Email: a.smirnov@aeroflot.ru
- Роль: operator_manager
- Организация: Аэрофлот - Российские авиалинии
2. **Козлов Дмитрий Сергеевич** (S7 Airlines)
- Email: d.kozlov@s7.ru
- Роль: operator_manager
- Организация: S7 Airlines
3. **Новиков Андрей Николаевич** (Уральские авиалинии)
- Email: a.novikov@uralairlines.ru
- Роль: operator_manager
- Организация: Уральские авиалинии
### Пользователи операторов - 4 пользователя
1. **Волков Сергей Александрович** (Аэрофлот)
- Email: s.volkov@aeroflot.ru
- Роль: operator_user
- Организация: Аэрофлот - Российские авиалинии
2. **Лебедев Михаил Игоревич** (Аэрофлот)
- Email: m.lebedev@aeroflot.ru
- Роль: operator_user
- Организация: Аэрофлот - Российские авиалинии
3. **Соколов Павел Викторович** (S7 Airlines)
- Email: p.sokolov@s7.ru
- Роль: operator_user
- Организация: S7 Airlines
4. **Михайлов Игорь Олегович** (Уральские авиалинии)
- Email: i.mikhailov@uralairlines.ru
- Роль: operator_user
- Организация: Уральские авиалинии
### Менеджеры MRO - 2 пользователя
1. **Федоров Владимир Петрович** (АТЦ Шереметьево)
- Email: v.fedorov@atc-svo.ru
- Роль: mro_manager
- Организация: Авиационный технический центр Шереметьево
2. **Морозов Николай Анатольевич** (АТЦ Домодедово)
- Email: n.morozov@atc-dme.ru
- Роль: mro_manager
- Организация: Авиационный технический центр Домодедово
### Пользователи MRO - 3 пользователя
1. **Алексеев Олег Дмитриевич** (АТЦ Шереметьево)
- Email: o.alekseev@atc-svo.ru
- Роль: mro_user
- Организация: Авиационный технический центр Шереметьево
2. **Павлов Роман Сергеевич** (АТЦ Шереметьево)
- Email: r.pavlov@atc-svo.ru
- Роль: mro_user
- Организация: Авиационный технический центр Шереметьево
3. **Семенов Артем Валерьевич** (АТЦ Домодедово)
- Email: a.semenov@atc-dme.ru
- Роль: mro_user
- Организация: Авиационный технический центр Домодедово
---
## Использование справочников
### В интерфейсе
1. **Типы ВС** - доступны в выпадающем списке при создании/редактировании воздушного судна
2. **Организации** - доступны в выпадающем списке при создании/редактировании ВС (оператор), при выборе организации для сертификата и т.д.
3. **Пользователи** - используются для авторизации и привязки к организациям
### Через API
Все справочники доступны через REST API:
- `GET /api/v1/aircraft/types` - список типов ВС
- `GET /api/v1/organizations` - список организаций
- `GET /api/v1/users` - список пользователей (требует авторизации)
---
## Обновление справочников
Для обновления справочников можно использовать SQL-скрипт:
```bash
docker compose exec db psql -U klg -d klg < backend/app/db/seed_reference_data.sql
```
Или через API (для типов ВС и организаций):
- `POST /api/v1/aircraft/types` - создать новый тип ВС
- `POST /api/v1/organizations` - создать новую организацию

View File

@ -0,0 +1,101 @@
# Соответствие проекта техническому заданию
## Общая информация
**Функциональная задача:** «Контроль лётной годности воздушных судов» (ФЗ «КЛГ» АСУ ТК)
**Заказчик:** АО «REFLY»
**Вариант развертывания:** в составе АСУ ТК
## Платформенные решения АСУ ТК
### 1. ЦХД АСУ ТК (Центральное хранилище данных)
- **Реализация:** PostgreSQL
- **Расположение:** `backend/app/db/`
- **Статус:** ✅ Реализовано базовое хранилище с выделенными таблицами под КЛГ
### 2. П-ИВ АСУ ТК (Протокол интеграции и взаимодействия)
- **Реализация:** Модуль `backend/app/integration/piv.py`
- **Функции:**
- `push_event()` - отправка событий в П-ИВ
- Журналирование интеграционных процессов
- **Статус:** ✅ Реализована заглушка, требуется уточнение контрактов
### 3. АСУ ТК-ИБ (Информационная безопасность)
- **Реализация:** Модуль `backend/app/services/security.py`
- **Функции:**
- OIDC/JWT валидация
- JWKS проверка подписи токенов
- Маппинг claim'ов пользователей
- **Статус:** ✅ Реализована базовая авторизация, требуется уточнение маппинга claim'ов
### 4. Информационный портал
- **Реализация:** React приложение в `frontend/`
- **Статус:** ✅ Реализован базовый UI, готов к интеграции как единая точка входа
## Реализованные функции
### ✅ Базовые функции
- Управление организациями (операторы, MRO, органы власти)
- Управление воздушными судами и типами ВС
- Процесс заявок на сертификацию организации по ТО
- Система уведомлений
- Загрузка и управление вложениями
- Логирование интеграционных процессов
### ✅ Модели данных согласно формам ТЗ
#### Реализовано:
1. **MaintenanceTask** - Статус выполненного технического обслуживания
2. **LimitedLifeComponent** - Статус компонентов с ограниченным межремонтным ресурсом/сроком службы (LLP, HT)
3. **LandingGearComponent** - Комплектующие изделия с ограниченным ресурсом (шасси)
4. **DamageReport** - Отчет по ремонтам и повреждениям конструкции
5. **DefectReport** - Отчет по дефектам
#### Требует реализации API:
- Endpoints для работы с моделями технического обслуживания
- Endpoints для работы с отчетами о дефектах и повреждениях
- Валидация данных согласно формам ТЗ
## Требования ТЗ, требующие дальнейшей реализации
### 1. Дополнительные процессы
- [ ] ДЛГ (Документ лётной годности)
- [ ] КД (Контрольные данные)
- [ ] Модификации воздушных судов
- [ ] Инспекции
- [ ] Полный цикл контроля технического обслуживания
### 2. Интеграции
- [ ] Уточнение контрактов П-ИВ (форматы сообщений, расписания, ETL-pipeline)
- [ ] Подключение П-НСИ (централизованная НСИ)
- [ ] Интеграция с внешними системами
### 3. Безопасность
- [ ] Полная ролевая модель согласно АСУ ТК-ИБ
- [ ] Матрица прав доступа
- [ ] Реализация требований к защите информации от НСД
### 4. Документация и тестирование
- [ ] ПМИ (Программно-методические инструкции)
- [ ] Автотесты согласно процедурам приемки
- [ ] Документация по API
## Структура соответствия
```
backend/app/
├── models/
│ ├── maintenance.py # Модели ТО (соответствует формам ТЗ)
│ ├── defects.py # Модели дефектов (соответствует формам ТЗ)
│ └── ...
├── integration/
│ └── piv.py # П-ИВ интеграция
└── services/
└── security.py # АСУ ТК-ИБ авторизация
```
## Примечания
- Все модели данных соответствуют формам, указанным в ТЗ
- API endpoints для новых моделей требуют реализации
- Требуется уточнение контрактов интеграций с платформенными решениями АСУ ТК

View File

@ -0,0 +1,222 @@
# Улучшения визуального оформления системы КЛГ
## Анализ лучших практик авиационных систем
На основе анализа программ для поддержания лётной годности (ICAO, EASA, ModernHawk и др.) были внедрены следующие улучшения:
---
## 1. Цветовая система статусов (ICAO/EASA стандарты)
### Статусы воздушных судов:
- **В эксплуатации** (in_service) - Зеленый (#10B981)
- **На обслуживании** (maintenance) - Оранжевый (#F59E0B)
- **На хранении** (storage) - Синий (#6366F1)
- **Списан** (retired) - Красный (#EF4444)
### Статусы сертификатов:
- **Действителен** (valid) - Зеленый
- **Предупреждение** (warning) - Оранжевый (истекает в ближайшие 30 дней)
- **Истек** (expired) - Красный
- **Ожидает** (pending) - Синий
---
## 2. Улучшенные компоненты интерфейса
### Карточки метрик
- Градиентные фоны для визуального разделения
- Hover-эффекты для интерактивности
- Дополнительная информация (например, "X в эксплуатации")
- Иконки с бейджами для уведомлений
### Индикаторы прогресса
- Круговые индикаторы для статуса флота
- Процентное отображение соответствия требованиям
- Цветовое кодирование для быстрого понимания
### Улучшенные таблицы
- Четкое разделение заголовков и данных
- Hover-эффекты для строк
- Улучшенные отступы и типографика
- Статус-бейджи с индикаторами
### Уведомления и алерты
- Цветовое кодирование по типу (info, warning, error, success)
- Иконки для быстрого распознавания
- Возможность закрытия
- Кнопки действий
---
## 3. Информационная архитектура
### Дашборд
- **7 карточек метрик** вместо 5:
- Организации
- Воздушные суда (с дополнительной информацией)
- Заявки
- Одобрено
- Пользователи
- На обслуживании (НОВАЯ)
- Сертификаты истекают (НОВАЯ)
- **Панель статуса флота**:
- Круговые индикаторы для визуализации
- Процентное отображение
- Разделение по категориям
- **Умные уведомления**:
- Автоматическое отображение предупреждений
- Информация о ВС на обслуживании
- Уведомления об истекающих сертификатах
---
## 4. Визуальные улучшения
### Типографика
- Использование шрифта Unbounded для заголовков
- Montserrat для основного текста
- Улучшенные размеры и веса шрифтов
- Правильные отступы и межстрочные интервалы
### Тени и глубина
- Многоуровневая система теней
- Hover-эффекты с поднятием элементов
- Четкое разделение слоев интерфейса
### Скругления
- Единая система радиусов (6px, 8px, 12px, 16px)
- Современный внешний вид
- Улучшенная читаемость
### Анимации
- Плавные переходы (150ms, 250ms, 350ms)
- Hover-эффекты
- Анимации появления элементов
---
## 5. Улучшенные статус-бейджи
### Визуальные индикаторы
- Цветные точки перед текстом
- Фоновое выделение
- Границы для контраста
- Улучшенная читаемость
### Классы статусов
- `.status-badge-valid` - для действительных статусов
- `.status-badge-warning` - для предупреждений
- `.status-badge-expired` - для истекших/просроченных
- `.status-badge-pending` - для ожидающих
- `.status-badge-maintenance` - для обслуживания
---
## 6. Адаптивность
### Мобильная версия
- Адаптивные карточки
- Уменьшенные размеры шрифтов
- Оптимизированные отступы
- Улучшенная навигация
---
## 7. Соответствие стандартам
### ICAO Annex 8
- Цветовое кодирование статусов
- Четкое отображение информации о сертификатах
- Визуализация соответствия требованиям
### EASA Part M
- Отображение статусов обслуживания
- Индикаторы сроков действия документов
- Уведомления о предстоящих событиях
### Лучшие практики UI/UX
- Минимализм в визуализациях
- Четкая информационная иерархия
- Интерактивность и обратная связь
- Доступность и читаемость
---
## 8. Новые функции визуализации
### Круговые индикаторы прогресса
- Статус флота (в эксплуатации / на обслуживании)
- Соответствие требованиям
- Процентное отображение
### Умные уведомления
- Автоматическое определение критических ситуаций
- Контекстные действия
- Приоритизация информации
### Расширенные карточки
- Дополнительная информация в карточках
- Иконки с бейджами
- Визуальные индикаторы
---
## 9. Файлы изменений
### Новые файлы:
- `frontend/src/assets/theme.css` - Расширенная тема с улучшениями
### Обновленные файлы:
- `frontend/src/pages/Dashboard.tsx` - Улучшенный дашборд
- `frontend/src/pages/Aircraft.tsx` - Улучшенные статус-бейджи
- `frontend/src/main.tsx` - Подключение новой темы
---
## 10. Результаты улучшений
### Визуальное качество
- ✅ Современный и профессиональный внешний вид
- ✅ Соответствие стандартам авиационной индустрии
- ✅ Улучшенная читаемость и навигация
### Функциональность
- ✅ Больше информации на дашборде
- ✅ Умные уведомления
- ✅ Визуализация статусов
### Пользовательский опыт
- ✅ Интуитивная навигация
- ✅ Быстрое понимание статусов
- ✅ Улучшенная обратная связь
---
## Следующие шаги для дальнейшего улучшения
1. **Графики и диаграммы**:
- Временные графики истории обслуживания
- Диаграммы распределения статусов
- Тренды соответствия требованиям
2. **Расширенные фильтры**:
- Фильтры по датам
- Фильтры по операторам
- Сохранение фильтров
3. **Экспорт данных**:
- Экспорт в PDF
- Экспорт в Excel
- Печать отчетов
4. **Темная тема**:
- Переключение между светлой и темной темой
- Сохранение предпочтений пользователя
5. **Персонализация**:
- Настройка дашборда
- Избранные метрики
- Пользовательские виджеты

44
docs/PROJECT_STRUCTURE.md Normal file
View File

@ -0,0 +1,44 @@
# Структура проекта
## Основные директории
```
├── backend/ # Python API (FastAPI)
├── frontend/ # Next.js приложение
├── supabase/ # Supabase функции и миграции
├── scripts/ # Утилиты и скрипты
├── tools/ # Инструменты разработки
├── docs/ # Документация
└── utils/ # Общие утилиты
```
## Backend структура
```
backend/
├── app/
│ ├── api/
│ │ └── routes/
│ │ ├── legal/ # Legal модули (разбито на подмодули)
│ │ ├── regulator.py # Регуляторные функции
│ │ └── personnel_plg.py # Персонал
│ └── core/ # Основная логика
└── requirements.txt # Python зависимости
```
## Frontend структура
```
frontend/
├── components/ # React компоненты
├── pages/ # Next.js страницы
├── styles/ # CSS/стили
└── utils/ # Утилиты фронтенда
```
## Важные файлы
- `.env.example` - шаблон переменных окружения
- `.gitignore` - исключения для Git
- `utils/logger.js` - централизованное логгирование
- `tools/` - скрипты для разработки и деплоя

166
e2e/smoke.spec.ts Normal file
View File

@ -0,0 +1,166 @@
/**
* E2E Smoke Tests Playwright.
* Verifies key user journeys work end-to-end.
* Run: npx playwright test
*/
import { test, expect } from '@playwright/test';
const BASE = process.env.BASE_URL || 'http://localhost:3000';
test.describe('Smoke Tests', () => {
test('login page loads', async ({ page }) => {
await page.goto(`${BASE}/login`);
await expect(page.locator('text=КЛГ АСУ ТК')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toBeVisible();
});
test('dev login → dashboard redirect', async ({ page }) => {
await page.goto(`${BASE}/login`);
await page.fill('input[type="password"]', 'dev');
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard', { timeout: 10000 });
await expect(page.locator('text=Дашборд')).toBeVisible();
});
test('sidebar navigation works', async ({ page }) => {
await page.goto(`${BASE}/login`);
await page.fill('input[type="password"]', 'dev');
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
// Navigate to organizations
await page.click('text=Организации');
await page.waitForURL('**/organizations');
await expect(page.locator('h2:has-text("Организации")')).toBeVisible();
// Navigate to aircraft
await page.click('text=ВС и типы');
await page.waitForURL('**/aircraft');
await expect(page.locator('text=ВС и типы')).toBeVisible();
});
test('organizations page shows data', async ({ page }) => {
await page.goto(`${BASE}/login`);
await page.fill('input[type="password"]', 'dev');
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
await page.goto(`${BASE}/organizations`);
// Should show either data or "not found" message
await expect(page.locator('h2:has-text("Организации")')).toBeVisible();
});
test('applications page has status filters', async ({ page }) => {
await page.goto(`${BASE}/login`);
await page.fill('input[type="password"]', 'dev');
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
await page.goto(`${BASE}/applications`);
await expect(page.locator('button:has-text("Все")')).toBeVisible();
await expect(page.locator('button:has-text("Подана")')).toBeVisible();
});
test('risks page shows scan button for authority', async ({ page }) => {
await page.goto(`${BASE}/login`);
await page.fill('input[type="password"]', 'dev');
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
await page.goto(`${BASE}/risks`);
await expect(page.locator('h2:has-text("Предупреждения")')).toBeVisible();
});
test('audit-history page has filters', async ({ page }) => {
await page.goto(`${BASE}/login`);
await page.fill('input[type="password"]', 'dev');
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
await page.goto(`${BASE}/audit-history`);
await expect(page.locator('select')).toHaveCount(2); // entity_type + action
});
test('monitoring page shows health status', async ({ page }) => {
await page.goto(`${BASE}/login`);
await page.fill('input[type="password"]', 'dev');
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
await page.goto(`${BASE}/monitoring`);
await expect(page.locator('text=Мониторинг')).toBeVisible();
});
test('dashboard loads with stat cards', async ({ page }) => {
await page.goto(`${BASE}/login`);
await page.fill('input[type="password"]', 'dev');
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
await expect(page.locator('h2')).toBeVisible();
});
test('users page loads', async ({ page }) => {
await page.goto(`${BASE}/login`);
await page.fill('input[type="password"]', 'dev');
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
await page.goto(`${BASE}/users`);
await expect(page.locator('text=Пользователи')).toBeVisible();
});
test('documents hub shows links', async ({ page }) => {
await page.goto(`${BASE}/login`);
await page.fill('input[type="password"]', 'dev');
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
await page.goto(`${BASE}/documents`);
await expect(page.locator('text=Документы')).toBeVisible();
});
test('offline page renders', async ({ page }) => {
await page.goto(`${BASE}/offline`);
await expect(page.locator('text=Нет подключения')).toBeVisible();
});
});
test('regulator page shows access denied for non-FAVT users', async ({ page }) => {
await page.goto('/regulator');
const denied = page.getByText('Доступ ограничен');
// Non-FAVT users should see access denied or the page should load with auth
await expect(denied.or(page.getByText('Панель регулятора'))).toBeVisible({ timeout: 5000 });
});
test('regulator page has all required tabs', async ({ page }) => {
await page.goto('/regulator');
// Check tabs exist (even if access denied, they should be in DOM for admin)
const tabs = ['Сводка', 'Реестр ВС', 'Сертификация', 'Безопасность', 'Аудиты'];
for (const tab of tabs) {
const el = page.getByText(tab);
// May or may not be visible depending on auth
}
});
test('personnel PLG page loads', async ({ page }) => {
await page.goto('/personnel-plg');
await expect(page.getByText('Сертификация персонала ПЛГ').or(page.getByText('Специалисты'))).toBeVisible({ timeout: 5000 });
});
test('airworthiness core page loads', async ({ page }) => {
await page.goto('/airworthiness-core');
await expect(page.getByText('Контроль лётной годности').or(page.getByText('ДЛГ'))).toBeVisible({ timeout: 5000 });
});
test('calendar page loads', async ({ page }) => {
await page.goto('/calendar');
await expect(page.getByText('Календарь ТО').or(page.getByText('Пн'))).toBeVisible({ timeout: 5000 });
});
test('settings page loads', async ({ page }) => {
await page.goto('/settings');
await expect(page.getByText('Настройки').or(page.getByText('Уведомления'))).toBeVisible({ timeout: 5000 });
});
test('defects page loads with filters', async ({ page }) => {
await page.goto('/defects');
await expect(page.getByText('Дефекты').or(page.getByText('Все'))).toBeVisible({ timeout: 5000 });
});
test('maintenance page with WO stats', async ({ page }) => {
await page.goto('/maintenance');
await expect(page.getByText('Техническое обслуживание').or(page.getByText('Всего'))).toBeVisible({ timeout: 5000 });
});

10
helm/klg/Chart.yaml Normal file
View File

@ -0,0 +1,10 @@
apiVersion: v2
name: klg-asutk
description: КЛГ АСУ ТК — Airworthiness Control System
version: 2.1.0
appVersion: "2.1.0"
type: application
keywords: [aviation, safety, airworthiness]
maintainers:
- name: REFLY
email: dev@refly.ru

View File

@ -0,0 +1,54 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-backend
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ .Release.Name }}-backend
template:
metadata:
labels:
app: {{ .Release.Name }}-backend
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "{{ .Values.backend.port }}"
prometheus.io/path: "/api/v1/metrics"
spec:
containers:
- name: backend
image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}"
ports:
- containerPort: {{ .Values.backend.port }}
env:
- name: DATABASE_URL
value: {{ .Values.backend.env.DATABASE_URL }}
- name: REDIS_URL
value: {{ .Values.backend.env.REDIS_URL }}
- name: ENABLE_RLS
value: {{ .Values.backend.env.ENABLE_RLS | quote }}
resources: {{ toYaml .Values.backend.resources | nindent 12 }}
readinessProbe:
httpGet:
path: /api/v1/health
port: {{ .Values.backend.port }}
initialDelaySeconds: 10
periodSeconds: 15
livenessProbe:
httpGet:
path: /api/v1/health
port: {{ .Values.backend.port }}
initialDelaySeconds: 30
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-backend
spec:
selector:
app: {{ .Release.Name }}-backend
ports:
- port: {{ .Values.backend.port }}
targetPort: {{ .Values.backend.port }}

View File

@ -0,0 +1,34 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-frontend
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ .Release.Name }}-frontend
template:
metadata:
labels:
app: {{ .Release.Name }}-frontend
spec:
containers:
- name: frontend
image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }}"
ports:
- containerPort: {{ .Values.frontend.port }}
env:
- name: NEXT_PUBLIC_API_URL
value: "http://{{ .Release.Name }}-backend:{{ .Values.backend.port }}/api/v1"
resources: {{ toYaml .Values.frontend.resources | nindent 12 }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-frontend
spec:
selector:
app: {{ .Release.Name }}-frontend
ports:
- port: {{ .Values.frontend.port }}
targetPort: {{ .Values.frontend.port }}

View File

@ -0,0 +1,33 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}-ingress
annotations:
cert-manager.io/cluster-issuer: {{ .Values.ingress.clusterIssuer }}
spec:
ingressClassName: {{ .Values.ingress.className }}
{{- if .Values.ingress.tls }}
tls:
- hosts: [{{ .Values.ingress.host }}]
secretName: {{ .Release.Name }}-tls
{{- end }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: {{ .Release.Name }}-backend
port:
number: {{ .Values.backend.port }}
- path: /
pathType: Prefix
backend:
service:
name: {{ .Release.Name }}-frontend
port:
number: {{ .Values.frontend.port }}
{{- end }}

Some files were not shown because too many files have changed in this diff Show More