Consolidation: KLG ASUTK + PAPA integration
- Unify API: lib/api.ts uses /api/v1, inbox uses /api/inbox (rewrites) - Remove localhost refs: openapi, inbox page - Add rewrites: /api/inbox|tmc -> inbox-server, /api/v1 -> FastAPI - Add stub routes: knowledge/insights, recommendations, search, log-error - Transfer from PAPA: prompts (inspection, tmc), scripts, supabase, data/tmc-requests - Fix inbox-server: ORDER BY created_at, package.json - Remove redundant app/api/inbox/files route (rewrites handle it) - knowledge/ in gitignore (large PDFs) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
0150aba4f5
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
node_modules/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
.env*.local
|
||||||
|
.next/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
backend/klg.db
|
||||||
|
knowledge/
|
||||||
BIN
KLG_TZ_Analysis_Consolidation.docx
Normal file
BIN
KLG_TZ_Analysis_Consolidation.docx
Normal file
Binary file not shown.
172
README.md
Normal file
172
README.md
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
# Прототип ФЗ «КЛГ» — вариант «под АСУ ТК»
|
||||||
|
|
||||||
|
Данный репозиторий содержит минимально работоспособный прототип серверной части ФЗ «Контроль лётной годности (КЛГ)»
|
||||||
|
в варианте развертывания *в составе АСУ ТК* согласно [Техническому заданию](docs/README.md).
|
||||||
|
|
||||||
|
**Заказчик:** АО «REFLY»
|
||||||
|
|
||||||
|
## Соответствие ТЗ (вариант «под АСУ ТК»)
|
||||||
|
|
||||||
|
### Платформенные решения АСУ ТК
|
||||||
|
|
||||||
|
- **ЦХД АСУ ТК** (Центральное хранилище данных): модель — PostgreSQL; выделенные схемы/таблицы под КЛГ.
|
||||||
|
- **П‑ИВ АСУ ТК** (Протокол интеграции и взаимодействия): модель — модуль `app/integration/piv.py` для событий и журналирования интеграционных процессов.
|
||||||
|
- **АСУ ТК‑ИБ** (Информационная безопасность): модель — модуль `app/services/security.py`, OIDC JWKS проверка; маппинг claim'ов подлежит уточнению.
|
||||||
|
- **Информационный портал**: в прототипе реализован базовый UI на React, API рассчитан на подключение портала как «единой точки входа».
|
||||||
|
|
||||||
|
### Что реализовано в прототипе
|
||||||
|
|
||||||
|
- Веб‑приложение (REST API) на **FastAPI**.
|
||||||
|
- Хранение данных в **PostgreSQL** (как модель ЦХД АСУ ТК).
|
||||||
|
- Единая авторизация: **OIDC/JWT‑валидация** (как модель АСУ ТК‑ИБ). В dev возможны HS256 токены.
|
||||||
|
- Приложения/процесс **«заявка на сертификацию организации по ТО»**:
|
||||||
|
- автонумерация;
|
||||||
|
- статусы (draft/submitted/under_review/remarks/approved/rejected/expired);
|
||||||
|
- замечания и автоматический 30‑дневный таймер (настраивается);
|
||||||
|
- уведомления (внутренняя таблица notifications).
|
||||||
|
- Загрузка файлов и привязка вложений (локальное файловое хранилище как заглушка).
|
||||||
|
- Логи загрузок/интеграционных процессов (ingest_job_logs) как часть контура П‑ИВ.
|
||||||
|
- Заглушка клиентского вызова П‑ИВ (push_event).
|
||||||
|
- Управление организациями (операторы, MRO, органы власти).
|
||||||
|
- Управление воздушными судами и их типами.
|
||||||
|
- Дашборд с фирменным стилем REFLY.
|
||||||
|
|
||||||
|
### Требования ТЗ, требующие дальнейшей реализации
|
||||||
|
|
||||||
|
Согласно ТЗ, для полного соответствия необходимо реализовать:
|
||||||
|
|
||||||
|
1. **Дополнительные процессы:**
|
||||||
|
- ДЛГ (Документ лётной годности)
|
||||||
|
- КД (Контрольные данные)
|
||||||
|
- Модификации воздушных судов
|
||||||
|
- Инспекции
|
||||||
|
- Контрольные карты программы ТО
|
||||||
|
- Отслеживание компонентов с ограниченным ресурсом (LLP, HT)
|
||||||
|
- Отчеты по ремонтам и повреждениям конструкции
|
||||||
|
- Отчеты по дефектам
|
||||||
|
- Отслеживание комплектующих изделий с ограниченным ресурсом (шасси)
|
||||||
|
|
||||||
|
2. **Формы данных согласно ТЗ:**
|
||||||
|
- Статус выполненного технического обслуживания
|
||||||
|
- Статус компонентов с ограниченным межремонтным ресурсом/сроком службы
|
||||||
|
- Отчет по ремонтам и повреждениям конструкции
|
||||||
|
- Отчет по дефектам
|
||||||
|
- Комплектующие изделия с ограниченным ресурсом (шасси)
|
||||||
|
- И другие формы, указанные в ТЗ
|
||||||
|
|
||||||
|
3. **Интеграции:**
|
||||||
|
- Уточнить контракты П‑ИВ: форматы сообщений, расписания, ETL‑pipeline, протоколирование.
|
||||||
|
- Подключить централизованную НСИ через П‑НСИ (справочники типов ВС, статусы, классификаторы).
|
||||||
|
|
||||||
|
4. **Безопасность и права доступа:**
|
||||||
|
- Реализовать полную ролевую модель и матрицу прав в терминах АСУ ТК‑ИБ.
|
||||||
|
- Реализовать требования к защите информации от НСД.
|
||||||
|
|
||||||
|
5. **Документация и тестирование:**
|
||||||
|
- Подготовить ПМИ (Программно-методические инструкции) согласно процедурам приемки.
|
||||||
|
- Реализовать автотесты согласно процедурам приемки.
|
||||||
|
|
||||||
|
## Быстрый старт (Docker Compose)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd klg_asutk_app
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
После запуска:
|
||||||
|
- Frontend: `http://localhost:8080`
|
||||||
|
- API: `http://localhost:8000/api/v1/health`
|
||||||
|
- API документация: `http://localhost:8000/docs`
|
||||||
|
|
||||||
|
## Авторизация (dev)
|
||||||
|
|
||||||
|
В dev включен режим `ALLOW_HS256_DEV_TOKENS=true`. Для вызовов API требуется заголовок:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <jwt>
|
||||||
|
```
|
||||||
|
|
||||||
|
Минимальный набор claim'ов (пример):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sub": "user-1",
|
||||||
|
"name": "Иван Иванов",
|
||||||
|
"email": "ivan@example.com",
|
||||||
|
"role": "operator_manager",
|
||||||
|
"org_id": "<ID организации>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Основные эндпоинты
|
||||||
|
|
||||||
|
### Организации
|
||||||
|
- `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/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` — список уведомлений
|
||||||
|
|
||||||
|
### Интеграция
|
||||||
|
- `POST /api/v1/ingest/logs` — логирование интеграционных процессов (authority)
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
klg_asutk_app/
|
||||||
|
├── backend/ # Backend на FastAPI
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── api/ # API endpoints
|
||||||
|
│ │ ├── core/ # Конфигурация
|
||||||
|
│ │ ├── db/ # Работа с БД
|
||||||
|
│ │ ├── integration/ # Интеграции (П-ИВ)
|
||||||
|
│ │ ├── models/ # Модели данных
|
||||||
|
│ │ ├── schemas/ # Pydantic схемы
|
||||||
|
│ │ └── services/ # Бизнес-логика
|
||||||
|
│ └── requirements.txt
|
||||||
|
├── frontend/ # Frontend на React + TypeScript
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── api/ # API клиенты
|
||||||
|
│ │ ├── components/ # React компоненты
|
||||||
|
│ │ ├── layout/ # Layout компоненты
|
||||||
|
│ │ ├── pages/ # Страницы приложения
|
||||||
|
│ │ └── assets/ # Шрифты, стили
|
||||||
|
│ └── package.json
|
||||||
|
├── docs/ # Документация
|
||||||
|
│ └── README.md # Информация о ТЗ
|
||||||
|
└── docker-compose.yml # Docker конфигурация
|
||||||
|
```
|
||||||
|
|
||||||
|
## Технологический стек
|
||||||
|
|
||||||
|
- **Backend:** FastAPI, SQLAlchemy, PostgreSQL, APScheduler
|
||||||
|
- **Frontend:** React, TypeScript, Ant Design, Vite
|
||||||
|
- **Инфраструктура:** Docker, Docker Compose
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
Проект разработан для АО «REFLY» согласно техническому заданию.
|
||||||
71
__tests__/api/ai-data.test.ts
Normal file
71
__tests__/api/ai-data.test.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Integration тесты для API endpoints
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach } from '@jest/globals';
|
||||||
|
import { POST } from '@/app/api/ai-data/route';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
describe('API: /api/ai-data', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Очистка моков перед каждым тестом
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен возвращать сводку при dataType=all', async () => {
|
||||||
|
const request = new NextRequest('http://localhost:3000/api/ai-data', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ dataType: 'all' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data).toHaveProperty('summary');
|
||||||
|
expect(data.summary).toHaveProperty('aircraft');
|
||||||
|
expect(data.summary).toHaveProperty('risks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен возвращать данные о ВС при dataType=aircraft', async () => {
|
||||||
|
const request = new NextRequest('http://localhost:3000/api/ai-data', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ dataType: 'aircraft' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data).toHaveProperty('dataType', 'aircraft');
|
||||||
|
expect(data).toHaveProperty('data');
|
||||||
|
expect(Array.isArray(data.data)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен возвращать ошибку при неверном dataType', async () => {
|
||||||
|
const request = new NextRequest('http://localhost:3000/api/ai-data', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ dataType: 'invalid' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(data).toHaveProperty('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен валидировать фильтры', async () => {
|
||||||
|
const request = new NextRequest('http://localhost:3000/api/ai-data', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
dataType: 'aircraft',
|
||||||
|
filters: {
|
||||||
|
registrationNumber: 'RA-12345',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
58
__tests__/lib/sanitize.test.ts
Normal file
58
__tests__/lib/sanitize.test.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Unit тесты для санитизации данных
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from '@jest/globals';
|
||||||
|
import { sanitizeText, sanitizeHtml, sanitizeUrl } from '@/lib/sanitize';
|
||||||
|
|
||||||
|
describe('Sanitization Functions', () => {
|
||||||
|
describe('sanitizeText', () => {
|
||||||
|
it('должен удалять угловые скобки', () => {
|
||||||
|
const input = '<script>alert("xss")</script>';
|
||||||
|
const result = sanitizeText(input);
|
||||||
|
expect(result).not.toContain('<');
|
||||||
|
expect(result).not.toContain('>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен обрезать пробелы', () => {
|
||||||
|
const input = ' текст ';
|
||||||
|
const result = sanitizeText(input);
|
||||||
|
expect(result).toBe('текст');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeHtml', () => {
|
||||||
|
it('должен удалять опасные теги', () => {
|
||||||
|
const input = '<script>alert("xss")</script><p>Безопасный текст</p>';
|
||||||
|
const result = sanitizeHtml(input);
|
||||||
|
expect(result).not.toContain('<script>');
|
||||||
|
expect(result).toContain('<p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен разрешать безопасные теги', () => {
|
||||||
|
const input = '<b>Жирный</b> <i>Курсив</i>';
|
||||||
|
const result = sanitizeHtml(input);
|
||||||
|
expect(result).toContain('<b>');
|
||||||
|
expect(result).toContain('<i>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeUrl', () => {
|
||||||
|
it('должен валидировать корректные HTTP URL', () => {
|
||||||
|
const input = 'https://example.com';
|
||||||
|
const result = sanitizeUrl(input);
|
||||||
|
expect(result).toBe('https://example.com/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен отклонять javascript: URL', () => {
|
||||||
|
const input = 'javascript:alert("xss")';
|
||||||
|
const result = sanitizeUrl(input);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен отклонять неверные протоколы', () => {
|
||||||
|
const input = 'file:///etc/passwd';
|
||||||
|
const result = sanitizeUrl(input);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
108
__tests__/lib/validation.test.ts
Normal file
108
__tests__/lib/validation.test.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Unit тесты для валидации данных
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from '@jest/globals';
|
||||||
|
import { aircraftSchema, riskSchema, organizationSchema } from '@/lib/validation';
|
||||||
|
|
||||||
|
describe('Validation Schemas', () => {
|
||||||
|
describe('aircraftSchema', () => {
|
||||||
|
it('должен валидировать корректные данные ВС', () => {
|
||||||
|
const validData = {
|
||||||
|
registrationNumber: 'RA-12345',
|
||||||
|
serialNumber: 'SN-001',
|
||||||
|
aircraftType: 'Boeing 737-800',
|
||||||
|
operator: 'Аэрофлот',
|
||||||
|
status: 'Активен' as const,
|
||||||
|
flightHours: 12500,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => aircraftSchema.parse(validData)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен отклонять неверный формат регистрационного номера', () => {
|
||||||
|
const invalidData = {
|
||||||
|
registrationNumber: '12345', // Неверный формат
|
||||||
|
serialNumber: 'SN-001',
|
||||||
|
aircraftType: 'Boeing 737-800',
|
||||||
|
operator: 'Аэрофлот',
|
||||||
|
status: 'Активен' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => aircraftSchema.parse(invalidData)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен отклонять неверный статус', () => {
|
||||||
|
const invalidData = {
|
||||||
|
registrationNumber: 'RA-12345',
|
||||||
|
serialNumber: 'SN-001',
|
||||||
|
aircraftType: 'Boeing 737-800',
|
||||||
|
operator: 'Аэрофлот',
|
||||||
|
status: 'Неверный статус', // Неверный статус
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => aircraftSchema.parse(invalidData)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен отклонять отрицательный налет', () => {
|
||||||
|
const invalidData = {
|
||||||
|
registrationNumber: 'RA-12345',
|
||||||
|
serialNumber: 'SN-001',
|
||||||
|
aircraftType: 'Boeing 737-800',
|
||||||
|
operator: 'Аэрофлот',
|
||||||
|
status: 'Активен' as const,
|
||||||
|
flightHours: -100, // Отрицательное значение
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => aircraftSchema.parse(invalidData)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('riskSchema', () => {
|
||||||
|
it('должен валидировать корректные данные риска', () => {
|
||||||
|
const validData = {
|
||||||
|
title: 'Высокий износ двигателя',
|
||||||
|
level: 'Высокий' as const,
|
||||||
|
category: 'Техническое состояние',
|
||||||
|
aircraft: 'RA-12345',
|
||||||
|
status: 'Требует внимания',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => riskSchema.parse(validData)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен отклонять неверный уровень риска', () => {
|
||||||
|
const invalidData = {
|
||||||
|
title: 'Риск',
|
||||||
|
level: 'Неверный уровень' as any,
|
||||||
|
category: 'Категория',
|
||||||
|
aircraft: 'RA-12345',
|
||||||
|
status: 'Статус',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => riskSchema.parse(invalidData)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('organizationSchema', () => {
|
||||||
|
it('должен валидировать корректные данные организации', () => {
|
||||||
|
const validData = {
|
||||||
|
name: 'Аэрофлот',
|
||||||
|
type: 'Авиакомпания',
|
||||||
|
status: 'Активна',
|
||||||
|
aircraftCount: 150,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => organizationSchema.parse(validData)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('должен отклонять пустое название', () => {
|
||||||
|
const invalidData = {
|
||||||
|
name: '', // Пустое название
|
||||||
|
type: 'Авиакомпания',
|
||||||
|
status: 'Активна',
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => organizationSchema.parse(invalidData)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
137
app/accessibility-test/page.tsx
Normal file
137
app/accessibility-test/page.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Страница для тестирования доступности
|
||||||
|
*/
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import AccessibleButton from '@/components/AccessibleButton';
|
||||||
|
import AccessibleInput from '@/components/AccessibleInput';
|
||||||
|
import AccessibleModal from '@/components/AccessibleModal';
|
||||||
|
import { useKeyboardNavigation } from '@/hooks/useKeyboardNavigation';
|
||||||
|
import { getWCAGLevel } from '@/lib/accessibility/colors';
|
||||||
|
|
||||||
|
export default function AccessibilityTestPage() {
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [contrastResult, setContrastResult] = useState<any>(null);
|
||||||
|
|
||||||
|
// Регистрация горячих клавиш
|
||||||
|
useKeyboardNavigation([
|
||||||
|
{
|
||||||
|
key: 'k',
|
||||||
|
ctrl: true,
|
||||||
|
handler: () => {
|
||||||
|
alert('Глобальный поиск (Ctrl+K)');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Escape',
|
||||||
|
handler: () => {
|
||||||
|
if (isModalOpen) {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const testContrast = () => {
|
||||||
|
const result = getWCAGLevel('#1e3a5f', '#ffffff', false);
|
||||||
|
setContrastResult(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<div style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||||
|
<h1 style={{ fontSize: '32px', fontWeight: 'bold', marginBottom: '24px' }}>
|
||||||
|
Тестирование доступности
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<section aria-labelledby="keyboard-nav-heading" style={{ marginBottom: '32px' }}>
|
||||||
|
<h2 id="keyboard-nav-heading" style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||||
|
Навигация с клавиатуры
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||||
|
<AccessibleButton
|
||||||
|
onClick={() => alert('Кнопка 1')}
|
||||||
|
ariaLabel="Тестовая кнопка 1"
|
||||||
|
>
|
||||||
|
Кнопка 1
|
||||||
|
</AccessibleButton>
|
||||||
|
<AccessibleButton
|
||||||
|
onClick={() => alert('Кнопка 2')}
|
||||||
|
ariaLabel="Тестовая кнопка 2"
|
||||||
|
>
|
||||||
|
Кнопка 2
|
||||||
|
</AccessibleButton>
|
||||||
|
<AccessibleButton
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
ariaLabel="Открыть модальное окно"
|
||||||
|
>
|
||||||
|
Открыть модальное окно
|
||||||
|
</AccessibleButton>
|
||||||
|
</div>
|
||||||
|
<p style={{ marginTop: '16px', fontSize: '14px', color: '#666' }}>
|
||||||
|
Попробуйте навигацию с клавиатуры: Tab для перехода, Enter/Space для активации, Escape для закрытия модальных окон.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="forms-heading" style={{ marginBottom: '32px' }}>
|
||||||
|
<h2 id="forms-heading" style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||||
|
Доступные формы
|
||||||
|
</h2>
|
||||||
|
<div style={{ maxWidth: '500px' }}>
|
||||||
|
<AccessibleInput
|
||||||
|
label="Имя пользователя"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
hint="Введите ваше имя пользователя"
|
||||||
|
/>
|
||||||
|
<AccessibleInput
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
error="Неверный формат email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="contrast-heading" style={{ marginBottom: '32px' }}>
|
||||||
|
<h2 id="contrast-heading" style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||||
|
Проверка контраста цветов
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={testContrast}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Проверить контраст
|
||||||
|
</button>
|
||||||
|
{contrastResult && (
|
||||||
|
<div style={{ marginTop: '16px', padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||||
|
<p>Контраст: {contrastResult.ratio.toFixed(2)}:1</p>
|
||||||
|
<p>WCAG AA: {contrastResult.aa ? '✅' : '❌'}</p>
|
||||||
|
<p>WCAG AAA: {contrastResult.aaa ? '✅' : '❌'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<AccessibleModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
title="Тестовое модальное окно"
|
||||||
|
description="Это модальное окно поддерживает навигацию с клавиатуры и фокус-ловку"
|
||||||
|
>
|
||||||
|
<p>Содержимое модального окна. Нажмите Escape или кликните вне окна для закрытия.</p>
|
||||||
|
</AccessibleModal>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
app/aircraft/layout.tsx
Normal file
2
app/aircraft/layout.tsx
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) { return children; }
|
||||||
134
app/aircraft/page.tsx
Normal file
134
app/aircraft/page.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Отключаем статическую генерацию для этой страницы
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import Logo from '@/components/Logo';
|
||||||
|
import AircraftTable from '@/components/AircraftTable';
|
||||||
|
import SearchModal from '@/components/SearchModal';
|
||||||
|
import Pagination from '@/components/Pagination';
|
||||||
|
import { useAircraftData } from '@/hooks/useSWRData';
|
||||||
|
import { useUrlParams } from '@/hooks/useUrlParams';
|
||||||
|
|
||||||
|
export default function AircraftPage() {
|
||||||
|
const { params } = useUrlParams();
|
||||||
|
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const page = params.page || 1;
|
||||||
|
const limit = params.limit || 50;
|
||||||
|
|
||||||
|
// Используем SWR для кэширования данных
|
||||||
|
const { data, error, isLoading, mutate } = useAircraftData({
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
paginate: true, // Используем server-side пагинацию
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleNavigate = (path: string) => {
|
||||||
|
window.location.href = path;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<div style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||||
|
<div style={{ color: 'red' }}>
|
||||||
|
Ошибка загрузки данных: {error.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aircraft = data?.data || [];
|
||||||
|
const pagination = data?.pagination || {
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<div id="main-content" role="main" style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<Logo size="large" />
|
||||||
|
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||||
|
Реестр воздушных судов гражданской авиации РФ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
Воздушные суда
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
{isLoading ? 'Загрузка...' : `Всего записей: ${pagination.total}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSearchModalOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#2196f3',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Поиск
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => mutate()}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#4caf50',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<div style={{ fontSize: '16px', color: '#666' }}>Загрузка данных...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AircraftTable aircraft={aircraft} />
|
||||||
|
{pagination.totalPages > 1 && (
|
||||||
|
<div style={{ marginTop: '24px' }}>
|
||||||
|
<Pagination
|
||||||
|
total={pagination.total}
|
||||||
|
limit={pagination.limit}
|
||||||
|
currentPage={pagination.page}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SearchModal
|
||||||
|
isOpen={isSearchModalOpen}
|
||||||
|
onClose={() => setIsSearchModalOpen(false)}
|
||||||
|
aircraft={aircraft}
|
||||||
|
searchType="aircraft"
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
app/api-docs/page.tsx
Normal file
80
app/api-docs/page.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export default function ApiDocsPage() {
|
||||||
|
const [SwaggerUI, setSwaggerUI] = useState<any>(null);
|
||||||
|
const [spec, setSpec] = useState<any>(null);
|
||||||
|
const swaggerRef = useRef<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Динамически загружаем SwaggerUI только на клиенте
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// Используем eval для обхода статического анализа Next.js
|
||||||
|
const loadSwaggerUI = async () => {
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const swaggerModule = await eval('import("swagger-ui-react")');
|
||||||
|
if (swaggerModule && swaggerModule.default) {
|
||||||
|
setSwaggerUI(() => swaggerModule.default);
|
||||||
|
// CSS загрузится автоматически
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('swagger-ui-react not installed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadSwaggerUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем OpenAPI спецификацию
|
||||||
|
fetch('/api/openapi')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
setSpec(data);
|
||||||
|
if (swaggerRef.current) {
|
||||||
|
swaggerRef.current.specActions.updateSpec(data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Failed to load OpenAPI spec:', err);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px' }}>
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<h1 style={{ fontSize: '32px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
API Документация
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: '16px', color: '#666' }}>
|
||||||
|
Интерактивная документация для AI endpoints
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!SwaggerUI ? (
|
||||||
|
<div style={{ padding: '40px', textAlign: 'center', color: '#666' }}>
|
||||||
|
<p>Загрузка Swagger UI...</p>
|
||||||
|
<p style={{ fontSize: '14px', marginTop: '8px', color: '#999' }}>
|
||||||
|
Если Swagger UI не загружается, установите: npm install swagger-ui-react
|
||||||
|
</p>
|
||||||
|
{spec && (
|
||||||
|
<pre style={{ marginTop: '20px', padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '4px', overflow: 'auto', textAlign: 'left' }}>
|
||||||
|
{JSON.stringify(spec, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ border: '1px solid #e0e0e0', borderRadius: '8px', overflow: 'hidden' }}>
|
||||||
|
<SwaggerUI
|
||||||
|
spec={spec}
|
||||||
|
url="/api/openapi"
|
||||||
|
ref={swaggerRef}
|
||||||
|
docExpansion="list"
|
||||||
|
defaultModelsExpandDepth={1}
|
||||||
|
defaultModelExpandDepth={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
441
app/api/ai-chat/route.ts
Normal file
441
app/api/ai-chat/route.ts
Normal file
@ -0,0 +1,441 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
import { logAudit, logSecurity, logError, logWarn } from '@/lib/logger';
|
||||||
|
import { sanitizeText } from '@/lib/sanitize';
|
||||||
|
import { withTimeout, TIMEOUTS } from '@/lib/resilience/timeout';
|
||||||
|
import { retryWithBackoff, RETRY_CONFIGS } from '@/lib/resilience/retry';
|
||||||
|
import { circuitBreakers } from '@/lib/resilience/circuit-breaker';
|
||||||
|
import { bulkheads } from '@/lib/resilience/bulkhead';
|
||||||
|
import { overloadProtectors } from '@/lib/resilience/overload-protection';
|
||||||
|
import { recordPerformance } from '@/lib/monitoring/metrics';
|
||||||
|
|
||||||
|
const openai = process.env.OPENAI_API_KEY
|
||||||
|
? new OpenAI({
|
||||||
|
apiKey: process.env.OPENAI_API_KEY,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
try {
|
||||||
|
// Overload protection
|
||||||
|
if (!overloadProtectors.ai.check()) {
|
||||||
|
recordPerformance('/api/ai-chat', Date.now() - startTime, 503, { method: 'POST' });
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'AI service overloaded, please try again later' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const identifier = getRateLimitIdentifier(request);
|
||||||
|
const limitCheck = rateLimit(identifier, 50, 60000); // 50 запросов в минуту
|
||||||
|
|
||||||
|
if (!limitCheck.allowed) {
|
||||||
|
logSecurity('Rate limit exceeded', { identifier, path: '/api/ai-chat' });
|
||||||
|
recordPerformance('/api/ai-chat', Date.now() - startTime, 429, { method: 'POST' });
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Превышен лимит запросов. Попробуйте позже.',
|
||||||
|
code: 'RATE_LIMIT_EXCEEDED',
|
||||||
|
resetTime: limitCheck.resetTime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: {
|
||||||
|
'X-RateLimit-Limit': '50',
|
||||||
|
'X-RateLimit-Remaining': limitCheck.remaining.toString(),
|
||||||
|
'X-RateLimit-Reset': limitCheck.resetTime.toString(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, есть ли файлы в запросе (FormData)
|
||||||
|
const contentType = request.headers.get('content-type') || '';
|
||||||
|
let message = '';
|
||||||
|
let history: any[] = [];
|
||||||
|
let files: any[] = [];
|
||||||
|
const fileContents: Array<{ name: string; type: string; content: string }> = [];
|
||||||
|
|
||||||
|
if (contentType.includes('multipart/form-data')) {
|
||||||
|
// Обработка FormData с файлами
|
||||||
|
const formData = await request.formData();
|
||||||
|
message = formData.get('message') as string || '';
|
||||||
|
const historyStr = formData.get('history') as string;
|
||||||
|
if (historyStr) {
|
||||||
|
try {
|
||||||
|
history = JSON.parse(historyStr);
|
||||||
|
} catch (e) {
|
||||||
|
history = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileCount = parseInt(formData.get('fileCount') as string || '0');
|
||||||
|
for (let i = 0; i < fileCount; i++) {
|
||||||
|
const file = formData.get(`file_${i}`) as File;
|
||||||
|
if (file) {
|
||||||
|
files.push({
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Читаем содержимое файла
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
// Парсим файл в зависимости от типа
|
||||||
|
try {
|
||||||
|
const { parseDocument } = await import('@/lib/ai/document-parser');
|
||||||
|
const content = await parseDocument(buffer, file.name, file.type);
|
||||||
|
fileContents.push({
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
content: content,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(`Ошибка парсинга файла ${file.name}`, error);
|
||||||
|
fileContents.push({
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
content: `[Не удалось прочитать файл: ${file.name}]`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Обычный JSON запрос
|
||||||
|
const body = await request.json();
|
||||||
|
message = body.message || '';
|
||||||
|
history = body.history || [];
|
||||||
|
files = body.files || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Санитизация входных данных
|
||||||
|
message = sanitizeText(message);
|
||||||
|
if (history && Array.isArray(history)) {
|
||||||
|
history = history.map((msg: any) => ({
|
||||||
|
...msg,
|
||||||
|
content: sanitizeText(msg.content || ''),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логирование использования ИИ агента
|
||||||
|
logAudit('AI_CHAT_REQUEST', 'ai-chat', {
|
||||||
|
identifier,
|
||||||
|
messageLength: message.length,
|
||||||
|
hasFiles: files && files.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!process.env.OPENAI_API_KEY) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'OpenAI API ключ не настроен', response: generateMockResponse(message, files) },
|
||||||
|
{ status: 200 } // Возвращаем 200, но используем заглушку
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Получаем доступные данные из системы для контекста
|
||||||
|
const dataContext = '\n\nДоступные базы данных в системе:\n' +
|
||||||
|
'- Воздушные суда: данные из реестра\n' +
|
||||||
|
'- Нормативные документы: 19+ документов (ICAO, EASA, FAA, МАК, АРМАК, ФАП)\n' +
|
||||||
|
'- Организации: авиакомпании и операторы\n' +
|
||||||
|
'- Риски: критические, высокие, средние, низкие\n' +
|
||||||
|
'- Аудиты: плановые и внеплановые\n' +
|
||||||
|
'- Чек-листы: предполетные осмотры, техническое обслуживание\n' +
|
||||||
|
'- Заявки: сертификация, разрешения\n' +
|
||||||
|
'- Пользователи: администраторы, инженеры, аудиторы\n' +
|
||||||
|
'- Документы: сертификаты, техническая документация, отчёты';
|
||||||
|
|
||||||
|
// Формируем системное сообщение с полной информацией о системе
|
||||||
|
const systemMessage = {
|
||||||
|
role: 'system' as const,
|
||||||
|
content: `Ты ИИ агент системы контроля лётной годности воздушных судов. ` +
|
||||||
|
`Твоя задача - помогать пользователям с управлением системой, анализом документов, ` +
|
||||||
|
`внесением данных в базу, поиском информации. ` +
|
||||||
|
`Отвечай на русском языке профессионально, точно и дружелюбно.\n\n` +
|
||||||
|
`У ТЕБЯ ЕСТЬ ПОЛНЫЙ ДОСТУП КО ВСЕМ БАЗАМ ДАННЫХ СИСТЕМЫ И ВОЗМОЖНОСТЬ ВНОСИТЬ ДАННЫЕ:\n\n` +
|
||||||
|
`ВАЖНО: Когда пользователь загружает документы (PDF, XLS, CSV, изображения), ` +
|
||||||
|
`ты должен проанализировать их содержимое и автоматически извлечь данные для внесения в систему. ` +
|
||||||
|
`Если в документах есть информация о воздушных судах, аудитах или чек-листах, ` +
|
||||||
|
`предложи пользователю внести эти данные в соответствующие карточки.\n\n` +
|
||||||
|
`1. ВОЗДУШНЫЕ СУДА (aircraft):\n` +
|
||||||
|
` - Регистрационные номера, типы, операторы, статусы\n` +
|
||||||
|
` - Для получения данных используй: POST /api/ai-data с dataType: "aircraft"\n` +
|
||||||
|
` - Можешь фильтровать по: registrationNumber, operator, type, status\n\n` +
|
||||||
|
`2. НОРМАТИВНЫЕ ДОКУМЕНТЫ (regulations):\n` +
|
||||||
|
` - Конвенция о международной гражданской авиации (Chicago Convention) с 19 приложениями (ICAO)\n` +
|
||||||
|
` - Документы ICAO (Annexes)\n` +
|
||||||
|
` - Правила EASA (Европейское агентство по безопасности авиации)\n` +
|
||||||
|
` - Правила FAA (Федеральное управление гражданской авиации США)\n` +
|
||||||
|
` - Документы МАК (Межгосударственный авиационный комитет)\n` +
|
||||||
|
` - Документы АРМАК (Агентство по регулированию гражданской авиации)\n` +
|
||||||
|
` - Авиационные правила РФ (ФАП-128, ФАП-145, ФАП-147, ФАП-21, ФАП-25, ФАП-29, ФАП-39, ФАП-50)\n` +
|
||||||
|
` - Воздушный кодекс РФ\n` +
|
||||||
|
` - Для получения данных используй: POST /api/ai-data с dataType: "regulations"\n\n` +
|
||||||
|
`3. ОРГАНИЗАЦИИ (organizations):\n` +
|
||||||
|
` - Авиакомпании и операторы\n` +
|
||||||
|
` - Для получения данных используй: POST /api/ai-data с dataType: "organizations"\n\n` +
|
||||||
|
`4. РИСКИ (risks):\n` +
|
||||||
|
` - Уровни: Критический, Высокий, Средний, Низкий\n` +
|
||||||
|
` - Категории, статусы, привязка к ВС\n` +
|
||||||
|
` - Для получения данных используй: POST /api/ai-data с dataType: "risks"\n\n` +
|
||||||
|
|
||||||
|
'5. АУДИТЫ (audits):\n' +
|
||||||
|
' - Плановые и внеплановые аудиты\n' +
|
||||||
|
' - Статусы: Запланирован, В процессе, Завершён\n' +
|
||||||
|
' - Для получения данных используй: POST /api/ai-data с dataType: "audits"\n\n' +
|
||||||
|
|
||||||
|
'6. ЧЕК-ЛИСТЫ (checklists):\n' +
|
||||||
|
' - Предполетные осмотры, техническое обслуживание\n' +
|
||||||
|
' - Для получения данных используй: POST /api/ai-data с dataType: "checklists"\n\n' +
|
||||||
|
|
||||||
|
'7. ЗАЯВКИ (applications):\n' +
|
||||||
|
' - Сертификация, разрешения на эксплуатацию\n' +
|
||||||
|
' - Для получения данных используй: POST /api/ai-data с dataType: "applications"\n\n' +
|
||||||
|
|
||||||
|
'8. ПОЛЬЗОВАТЕЛИ (users):\n' +
|
||||||
|
' - Администраторы, инженеры, аудиторы\n' +
|
||||||
|
' - Для получения данных используй: POST /api/ai-data с dataType: "users"\n\n' +
|
||||||
|
|
||||||
|
'9. ДОКУМЕНТЫ (documents):\n' +
|
||||||
|
' - Сертификаты, техническая документация, отчёты\n' +
|
||||||
|
' - Для получения данных используй: POST /api/ai-data с dataType: "documents"\n\n' +
|
||||||
|
|
||||||
|
'КАК РАБОТАТЬ С ДАННЫМИ:\n' +
|
||||||
|
'- Когда пользователь спрашивает о данных, автоматически запрашивай их через /api/ai-data\n' +
|
||||||
|
'- Используй полученные данные для точных ответов\n' +
|
||||||
|
'- При поиске применяй фильтры для уточнения результатов\n' +
|
||||||
|
'- Если пользователь просит добавить данные, уточни необходимую информацию и предложи структурированный формат\n' +
|
||||||
|
'- При ответах на вопросы о нормативных требованиях ссылайся на соответствующие документы\n\n' +
|
||||||
|
|
||||||
|
'ПРИМЕРЫ ЗАПРОСОВ:\n' +
|
||||||
|
'- "Сколько воздушных судов у Аэрофлота?" → запроси aircraft с фильтром operator: "Аэрофлот"\n' +
|
||||||
|
'- "Какие критические риски есть?" → запроси risks с фильтром level: "Критический"\n' +
|
||||||
|
'- "Покажи все аудиты" → запроси audits\n' +
|
||||||
|
'- "Найди ВС RA-12345" → запроси aircraft с фильтром registrationNumber: "RA-12345"\n\n' +
|
||||||
|
|
||||||
|
dataContext
|
||||||
|
};
|
||||||
|
|
||||||
|
// Анализируем запрос пользователя и определяем, нужно ли запросить данные
|
||||||
|
const userMessageContent = message;
|
||||||
|
let dataToInclude = '';
|
||||||
|
|
||||||
|
// Определяем, запрашивает ли пользователь данные
|
||||||
|
const lowerMessage = message.toLowerCase();
|
||||||
|
const dataKeywords: Record<string, string[]> = {
|
||||||
|
aircraft: ['вс', 'воздушн', 'самолёт', 'самолет', 'aircraft', 'регистрац'],
|
||||||
|
risks: ['риск', 'риски', 'опасн', 'проблем'],
|
||||||
|
audits: ['аудит', 'проверк', 'инспекц'],
|
||||||
|
organizations: ['организац', 'компани', 'авиакомпани', 'оператор'],
|
||||||
|
checklists: ['чек-лист', 'чеклист', 'осмотр', 'проверк'],
|
||||||
|
applications: ['заявк', 'сертификац', 'разрешен'],
|
||||||
|
users: ['пользовател', 'пользователь', 'пользователи', 'user'],
|
||||||
|
documents: ['документ', 'сертификат', 'отчёт', 'отчет'],
|
||||||
|
regulations: ['норматив', 'правил', 'требован', 'стандарт', 'fap', 'icao', 'easa', 'faa'],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Определяем тип данных для запроса
|
||||||
|
let requestedDataType: string | null = null;
|
||||||
|
for (const [dataType, keywords] of Object.entries(dataKeywords)) {
|
||||||
|
if (keywords.some(keyword => lowerMessage.includes(keyword))) {
|
||||||
|
requestedDataType = dataType;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если запрашиваются данные, получаем их через внутренний вызов
|
||||||
|
if (requestedDataType) {
|
||||||
|
try {
|
||||||
|
// Используем внутренний импорт для получения данных
|
||||||
|
const { getDataForAI } = await import('../ai-data-helper');
|
||||||
|
const dataResult = await getDataForAI(requestedDataType, extractFilters(message));
|
||||||
|
|
||||||
|
if (dataResult && dataResult.data) {
|
||||||
|
dataToInclude = `\n\n[ДАННЫЕ ИЗ СИСТЕМЫ]\n` +
|
||||||
|
`Тип: ${dataResult.dataType}\n` +
|
||||||
|
`Количество записей: ${dataResult.count}\n` +
|
||||||
|
`Данные: ${JSON.stringify(dataResult.data.slice(0, 10), null, 2)}${dataResult.count > 10 ? `\n... и еще ${dataResult.count - 10} записей` : ''}\n` +
|
||||||
|
`[КОНЕЦ ДАННЫХ]`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logWarn('Не удалось получить данные для ИИ агента', { error: String(error) });
|
||||||
|
// Продолжаем без данных
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем массив сообщений для OpenAI
|
||||||
|
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
|
||||||
|
systemMessage,
|
||||||
|
...history.map((msg: any) => ({
|
||||||
|
role: msg.role === 'user' ? 'user' as const : 'assistant' as const,
|
||||||
|
content: msg.content,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
role: 'user' as const,
|
||||||
|
content: `${userMessageContent}${files && files.length > 0
|
||||||
|
? `\n\nПрикреплены файлы: ${files.map((f: any) => f.name).join(', ')}`
|
||||||
|
: ''}${fileContents.length > 0
|
||||||
|
? `\n\n[СОДЕРЖИМОЕ ФАЙЛОВ]\n${fileContents.map(f => `Файл: ${f.name}\n${f.content.substring(0, 5000)}${f.content.length > 5000 ? '...' : ''}`).join('\n\n---\n\n')}\n[КОНЕЦ СОДЕРЖИМОГО ФАЙЛОВ]`
|
||||||
|
: ''}${dataToInclude}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Запрос к OpenAI API
|
||||||
|
if (!openai) {
|
||||||
|
throw new Error('OpenAI API ключ не настроен');
|
||||||
|
}
|
||||||
|
if (!openai) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'OpenAI API key not configured' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Используем bulkhead, circuit breaker, retry и timeout для устойчивости
|
||||||
|
const completion = await bulkheads.ai.execute(async () => {
|
||||||
|
return circuitBreakers.openai.execute(async () => {
|
||||||
|
return retryWithBackoff(
|
||||||
|
() =>
|
||||||
|
withTimeout(
|
||||||
|
openai.chat.completions.create({
|
||||||
|
model: 'gpt-4o-mini', // Используем более доступную модель
|
||||||
|
messages: messages,
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 1000,
|
||||||
|
}),
|
||||||
|
TIMEOUTS.OPENAI_API,
|
||||||
|
'OpenAI API request timed out'
|
||||||
|
),
|
||||||
|
{
|
||||||
|
...RETRY_CONFIGS.OPENAI_API,
|
||||||
|
onRetry: (attempt, error) => {
|
||||||
|
logWarn(`OpenAI chat retry attempt ${attempt}`, { error: error.message });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const aiResponse = completion.choices[0]?.message?.content || 'Извините, не удалось получить ответ.';
|
||||||
|
|
||||||
|
// Запись метрики производительности
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
recordPerformance('/api/ai-chat', duration, 200, { method: 'POST' });
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
response: aiResponse,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (openaiError: any) {
|
||||||
|
logError('Ошибка OpenAI API', openaiError);
|
||||||
|
|
||||||
|
// Если ошибка API, возвращаем заглушку
|
||||||
|
return NextResponse.json({
|
||||||
|
response: `${generateMockResponse(message, files)}\n\n⚠️ Примечание: OpenAI API временно недоступен, используется локальная обработка.`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: '/api/ai-chat',
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для извлечения фильтров из запроса пользователя
|
||||||
|
function extractFilters(message: string): any {
|
||||||
|
const filters: any = {};
|
||||||
|
const lowerMessage = message.toLowerCase();
|
||||||
|
|
||||||
|
// Извлечение регистрационного номера ВС
|
||||||
|
const regNumberMatch = message.match(/RA-[\dA-Z]+/i);
|
||||||
|
if (regNumberMatch) {
|
||||||
|
filters.registrationNumber = regNumberMatch[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлечение оператора/компании
|
||||||
|
const companies = ['аэрофлот', 's7', 'уральск', 'победа', 'нордавиа'];
|
||||||
|
for (const company of companies) {
|
||||||
|
if (lowerMessage.includes(company)) {
|
||||||
|
filters.operator = company;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлечение уровня риска
|
||||||
|
if (lowerMessage.includes('критическ')) {
|
||||||
|
filters.level = 'Критический';
|
||||||
|
} else if (lowerMessage.includes('высок')) {
|
||||||
|
filters.level = 'Высокий';
|
||||||
|
} else if (lowerMessage.includes('средн')) {
|
||||||
|
filters.level = 'Средний';
|
||||||
|
} else if (lowerMessage.includes('низк')) {
|
||||||
|
filters.level = 'Низкий';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлечение статуса
|
||||||
|
if (lowerMessage.includes('активн')) {
|
||||||
|
filters.status = 'Активен';
|
||||||
|
} else if (lowerMessage.includes('обслуживан')) {
|
||||||
|
filters.status = 'На обслуживании';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(filters).length > 0 ? filters : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция-заглушка для случаев, когда API недоступен
|
||||||
|
function generateMockResponse(message: string, files: any[]): string {
|
||||||
|
const lowerMessage = message.toLowerCase();
|
||||||
|
|
||||||
|
if (lowerMessage.includes('добавить') || lowerMessage.includes('создать')) {
|
||||||
|
if (lowerMessage.includes('вс') || lowerMessage.includes('воздушн')) {
|
||||||
|
return `Для добавления воздушного судна мне нужна следующая информация:\n\n` +
|
||||||
|
`1. Регистрационный номер (например: RA-12345)\n` +
|
||||||
|
`2. Серийный номер\n` +
|
||||||
|
`3. Тип ВС (например: Boeing 737-800)\n` +
|
||||||
|
`4. Оператор (название компании)\n` +
|
||||||
|
`5. Статус (Активен/На обслуживании)\n\n` +
|
||||||
|
`Предоставьте эти данные в структурированном виде, и я внесу их в базу.`;
|
||||||
|
}
|
||||||
|
if (lowerMessage.includes('риск')) {
|
||||||
|
return `Для добавления риска требуется:\n\n` +
|
||||||
|
`1. Название риска\n` +
|
||||||
|
`2. Уровень: Критический/Высокий/Средний/Низкий\n` +
|
||||||
|
`3. Категория\n` +
|
||||||
|
`4. ВС (регистрационный номер)\n` +
|
||||||
|
`5. Описание\n\n` +
|
||||||
|
`Укажите эти данные для автоматического внесения.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerMessage.includes('найти') || lowerMessage.includes('поиск')) {
|
||||||
|
return `Я могу помочь с поиском:\n\n` +
|
||||||
|
`• Воздушное судно по номеру\n` +
|
||||||
|
`• Риски по уровню или категории\n` +
|
||||||
|
`• Документы по типу или ВС\n` +
|
||||||
|
`• Аудиты по организации или дате\n\n` +
|
||||||
|
`Уточните, что именно нужно найти?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
return `Получено ${files.length} файл(ов). Анализирую содержимое...\n\n` +
|
||||||
|
`Если в файлах есть структурированные данные (списки ВС, риски, документы), ` +
|
||||||
|
`я могу автоматически извлечь их и предложить внести в базу данных.\n\n` +
|
||||||
|
`Продолжить анализ?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Я ИИ агент системы контроля лётной годности. Могу помочь с:\n\n` +
|
||||||
|
`✅ Добавлением данных в базу (ВС, риски, документы, аудиты)\n` +
|
||||||
|
`✅ Поиском информации по системе\n` +
|
||||||
|
`✅ Анализом прикрепленных файлов\n` +
|
||||||
|
`✅ Генерацией отчетов\n` +
|
||||||
|
`✅ Ответами на вопросы о системе\n\n` +
|
||||||
|
`Что именно вам нужно?`;
|
||||||
|
}
|
||||||
193
app/api/ai-data-helper.ts
Normal file
193
app/api/ai-data-helper.ts
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import { loadAircraftRegistry } from '@/lib/load-registry';
|
||||||
|
import { RegulationDocument } from '@/lib/regulations';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вспомогательные функции для получения данных для ИИ агента
|
||||||
|
* Используется внутри серверных API routes
|
||||||
|
*/
|
||||||
|
export async function getDataForAI(dataType: string, filters?: any) {
|
||||||
|
switch (dataType) {
|
||||||
|
case 'aircraft':
|
||||||
|
return await getAircraftData(filters);
|
||||||
|
case 'regulations':
|
||||||
|
return await getRegulationsData(filters);
|
||||||
|
case 'organizations':
|
||||||
|
return await getOrganizationsData(filters);
|
||||||
|
case 'risks':
|
||||||
|
return await getRisksData(filters);
|
||||||
|
case 'audits':
|
||||||
|
return await getAuditsData(filters);
|
||||||
|
case 'checklists':
|
||||||
|
return await getChecklistsData(filters);
|
||||||
|
case 'applications':
|
||||||
|
return await getApplicationsData(filters);
|
||||||
|
case 'users':
|
||||||
|
return await getUsersData(filters);
|
||||||
|
case 'documents':
|
||||||
|
return await getDocumentsData(filters);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAircraftData(filters?: any) {
|
||||||
|
try {
|
||||||
|
const aircraft = await loadAircraftRegistry();
|
||||||
|
|
||||||
|
let filtered = aircraft;
|
||||||
|
if (filters) {
|
||||||
|
if (filters.registrationNumber) {
|
||||||
|
filtered = filtered.filter((a: any) =>
|
||||||
|
a.registrationNumber?.toLowerCase().includes(filters.registrationNumber.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filters.operator) {
|
||||||
|
filtered = filtered.filter((a: any) =>
|
||||||
|
a.operator?.toLowerCase().includes(filters.operator.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filters.type) {
|
||||||
|
filtered = filtered.filter((a: any) =>
|
||||||
|
a.aircraftType?.toLowerCase().includes(filters.type.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (filters.status) {
|
||||||
|
filtered = filtered.filter((a: any) => a.status === filters.status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataType: 'aircraft',
|
||||||
|
count: filtered.length,
|
||||||
|
data: filtered,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const mockAircraft = [
|
||||||
|
{ id: '1', registrationNumber: 'RA-12345', serialNumber: 'SN-001', aircraftType: 'Boeing 737-800', operator: 'Аэрофлот', status: 'Активен', flightHours: 12500 },
|
||||||
|
{ id: '2', registrationNumber: 'RA-67890', serialNumber: 'SN-002', aircraftType: 'Airbus A320', operator: 'S7 Airlines', status: 'Активен', flightHours: 8900 },
|
||||||
|
{ id: '3', registrationNumber: 'RA-11111', serialNumber: 'SN-003', aircraftType: 'Boeing 777-300ER', operator: 'Аэрофлот', status: 'На обслуживании', flightHours: 15200 },
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
dataType: 'aircraft',
|
||||||
|
count: mockAircraft.length,
|
||||||
|
data: mockAircraft,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRegulationsData(_filters?: any) {
|
||||||
|
const mockRegulations: RegulationDocument[] = [
|
||||||
|
{
|
||||||
|
id: 'chicago-convention',
|
||||||
|
title: 'Конвенция о международной гражданской авиации (Chicago Convention)',
|
||||||
|
source: 'ICAO',
|
||||||
|
type: 'convention',
|
||||||
|
category: 'Основополагающий документ',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'Конвенция о международной гражданской авиации...',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataType: 'regulations',
|
||||||
|
count: mockRegulations.length,
|
||||||
|
data: mockRegulations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrganizationsData(_filters?: any) {
|
||||||
|
const mockOrganizations = [
|
||||||
|
{ id: '1', name: 'Аэрофлот', type: 'Авиакомпания', aircraftCount: 150, status: 'Активна' },
|
||||||
|
{ id: '2', name: 'S7 Airlines', type: 'Авиакомпания', aircraftCount: 95, status: 'Активна' },
|
||||||
|
{ id: '3', name: 'Уральские авиалинии', type: 'Авиакомпания', aircraftCount: 45, status: 'Активна' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataType: 'organizations',
|
||||||
|
count: mockOrganizations.length,
|
||||||
|
data: mockOrganizations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRisksData(_filters?: any) {
|
||||||
|
const mockRisks = [
|
||||||
|
{ id: '1', title: 'Высокий износ двигателя', level: 'Высокий', category: 'Техническое состояние', aircraft: 'RA-12345', status: 'Требует внимания', date: '2025-01-20' },
|
||||||
|
{ id: '2', title: 'Недостаточное техническое обслуживание', level: 'Средний', category: 'Обслуживание', aircraft: 'RA-67890', status: 'В работе', date: '2025-01-19' },
|
||||||
|
{ id: '3', title: 'Критическая неисправность системы управления', level: 'Критический', category: 'Техническое состояние', aircraft: 'RA-11111', status: 'Требует внимания', date: '2025-01-21' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataType: 'risks',
|
||||||
|
count: mockRisks.length,
|
||||||
|
data: mockRisks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuditsData(_filters?: any) {
|
||||||
|
const mockAudits = [
|
||||||
|
{ id: '1', organization: 'Аэрофлот', type: 'Плановый', status: 'Запланирован', date: '2025-02-01', result: null },
|
||||||
|
{ id: '2', organization: 'S7 Airlines', type: 'Внеплановый', status: 'В процессе', date: '2025-01-21', result: null },
|
||||||
|
{ id: '3', organization: 'Уральские авиалинии', type: 'Плановый', status: 'Завершён', date: '2024-12-15', result: 'Соответствует требованиям' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataType: 'audits',
|
||||||
|
count: mockAudits.length,
|
||||||
|
data: mockAudits,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChecklistsData(_filters?: any) {
|
||||||
|
const mockChecklists = [
|
||||||
|
{ id: '1', name: 'Предполетный осмотр', type: 'Ежедневный', aircraft: 'RA-12345', status: 'Выполнен', date: '2025-01-20' },
|
||||||
|
{ id: '2', name: 'Техническое обслуживание', type: 'Периодический', aircraft: 'RA-67890', status: 'В процессе', date: '2025-01-21' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataType: 'checklists',
|
||||||
|
count: mockChecklists.length,
|
||||||
|
data: mockChecklists,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getApplicationsData(_filters?: any) {
|
||||||
|
const mockApplications = [
|
||||||
|
{ id: '1', type: 'Сертификация ВС', aircraft: 'RA-12345', status: 'На рассмотрении', date: '2025-01-15' },
|
||||||
|
{ id: '2', type: 'Разрешение на эксплуатацию', aircraft: 'RA-67890', status: 'Одобрена', date: '2025-01-10' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataType: 'applications',
|
||||||
|
count: mockApplications.length,
|
||||||
|
data: mockApplications,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUsersData(_filters?: any) {
|
||||||
|
const mockUsers = [
|
||||||
|
{ id: '1', name: 'Иванов И.И.', role: 'Администратор', email: 'ivanov@example.com', status: 'Активен' },
|
||||||
|
{ id: '2', name: 'Петров П.П.', role: 'Инженер', email: 'petrov@example.com', status: 'Активен' },
|
||||||
|
{ id: '3', name: 'Сидоров С.С.', role: 'Аудитор', email: 'sidorov@example.com', status: 'Активен' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataType: 'users',
|
||||||
|
count: mockUsers.length,
|
||||||
|
data: mockUsers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDocumentsData(_filters?: any) {
|
||||||
|
const mockDocuments = [
|
||||||
|
{ id: '1', name: 'Сертификат лётной годности', type: 'Сертификат', aircraft: 'RA-12345', date: '2025-01-15', status: 'Действителен' },
|
||||||
|
{ id: '2', name: 'Техническая документация', type: 'Техническая', aircraft: 'RA-67890', date: '2025-01-10', status: 'Действителен' },
|
||||||
|
{ id: '3', name: 'Отчёт о техническом обслуживании', type: 'Отчёт', aircraft: 'RA-11111', date: '2025-01-20', status: 'Требует обновления' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataType: 'documents',
|
||||||
|
count: mockDocuments.length,
|
||||||
|
data: mockDocuments,
|
||||||
|
};
|
||||||
|
}
|
||||||
62
app/api/ai-data/route.ts
Normal file
62
app/api/ai-data/route.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getDataForAI } from '../ai-data-helper';
|
||||||
|
import { filterSchema } from '@/lib/validation';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
import { logAudit } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API endpoint для предоставления ИИ агенту доступа ко всем базам данных
|
||||||
|
* ИИ агент может запрашивать данные через этот endpoint
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { query: _query, dataType, filters } = body;
|
||||||
|
|
||||||
|
// Валидация фильтров
|
||||||
|
if (filters) {
|
||||||
|
filterSchema.parse(filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логирование доступа
|
||||||
|
const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? 'unknown';
|
||||||
|
logAudit('AI_DATA_ACCESS', dataType || 'all', { ip, dataType, filters });
|
||||||
|
|
||||||
|
if (dataType === 'all') {
|
||||||
|
// Возвращаем сводку по всем базам данным
|
||||||
|
return NextResponse.json({
|
||||||
|
summary: {
|
||||||
|
aircraft: { count: 3, description: 'Воздушные суда' },
|
||||||
|
regulations: { count: 19, description: 'Нормативные документы' },
|
||||||
|
organizations: { count: 3, description: 'Организации' },
|
||||||
|
risks: { count: 3, description: 'Риски' },
|
||||||
|
audits: { count: 3, description: 'Аудиты' },
|
||||||
|
checklists: { count: 2, description: 'Чек-листы' },
|
||||||
|
applications: { count: 2, description: 'Заявки' },
|
||||||
|
users: { count: 3, description: 'Пользователи' },
|
||||||
|
documents: { count: 3, description: 'Документы' },
|
||||||
|
},
|
||||||
|
message: 'Используйте dataType для получения детальной информации по каждой базе данных',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем данные через helper функцию
|
||||||
|
const result = await getDataForAI(dataType, filters);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Неизвестный тип данных', availableTypes: ['aircraft', 'regulations', 'organizations', 'risks', 'audits', 'checklists', 'applications', 'users', 'documents', 'all'] },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: '/api/ai-data',
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
108
app/api/ai/agent/route.ts
Normal file
108
app/api/ai/agent/route.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { processNaturalLanguageQuery, detectIntent } from '@/lib/ai/natural-language-interface';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
import { withTimeout, TIMEOUTS } from '@/lib/resilience/timeout';
|
||||||
|
import { bulkheads } from '@/lib/resilience/bulkhead';
|
||||||
|
import { overloadProtectors } from '@/lib/resilience/overload-protection';
|
||||||
|
import { tracedOperation, tracer } from '@/lib/tracing/tracer';
|
||||||
|
import { recordPerformance } from '@/lib/monitoring/metrics';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const traceContext = tracer.createTrace('POST /api/ai/agent', {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/ai/agent',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Overload protection
|
||||||
|
if (!overloadProtectors.ai.check()) {
|
||||||
|
recordPerformance('/api/ai/agent', Date.now() - startTime, 503, { method: 'POST' });
|
||||||
|
tracer.finishSpan(traceContext, 'error', new Error('Service overloaded'));
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'AI service overloaded, please try again later' },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const rateLimitResult = rateLimit(getRateLimitIdentifier(request));
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
recordPerformance('/api/ai/agent', Date.now() - startTime, 429, { method: 'POST' });
|
||||||
|
tracer.finishSpan(traceContext, 'error', new Error('Rate limit exceeded'));
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Слишком много запросов' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { query, mode = 'copilot', context } = body;
|
||||||
|
|
||||||
|
if (!query || typeof query !== 'string') {
|
||||||
|
tracer.finishSpan(traceContext, 'error', new Error('Missing query parameter'));
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Параметр query обязателен' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracer.addTag(traceContext, 'mode', mode);
|
||||||
|
tracer.addTag(traceContext, 'query_length', query.length.toString());
|
||||||
|
|
||||||
|
// Используем bulkhead для изоляции AI операций
|
||||||
|
const result = await bulkheads.ai.execute(async () => {
|
||||||
|
// Определяем намерение с timeout и tracing
|
||||||
|
const intent = await tracedOperation(
|
||||||
|
traceContext,
|
||||||
|
'detect-intent',
|
||||||
|
async () => {
|
||||||
|
return await withTimeout(
|
||||||
|
detectIntent(query),
|
||||||
|
TIMEOUTS.OPENAI_API / 2,
|
||||||
|
'Intent detection timeout'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Обрабатываем запрос с timeout и tracing
|
||||||
|
const response = await tracedOperation(
|
||||||
|
traceContext,
|
||||||
|
'process-natural-language-query',
|
||||||
|
async () => {
|
||||||
|
return await withTimeout(
|
||||||
|
processNaturalLanguageQuery({
|
||||||
|
query,
|
||||||
|
mode: mode === 'autonomous' ? 'autonomous' : 'copilot',
|
||||||
|
context,
|
||||||
|
}),
|
||||||
|
TIMEOUTS.OPENAI_API,
|
||||||
|
'Natural language query processing timeout'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ mode }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
intent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
recordPerformance('/api/ai/agent', duration, 200, { method: 'POST' });
|
||||||
|
tracer.finishSpan(traceContext, 'completed');
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error: any) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
recordPerformance('/api/ai/agent', duration, 500, { method: 'POST' });
|
||||||
|
tracer.finishSpan(traceContext, 'error', error);
|
||||||
|
return handleError(error, {
|
||||||
|
path: '/api/ai/agent',
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
115
app/api/aircraft/route.ts
Normal file
115
app/api/aircraft/route.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCachedAircraft } from '@/lib/api/cached-api';
|
||||||
|
import { paginatedQuery } from '@/lib/database/query-optimizer';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
import { aircraftFiltersSchema, validateRequestParams } from '@/lib/validation/api-validation';
|
||||||
|
import { logWarn } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Route для получения данных о воздушных судах
|
||||||
|
* Поддерживает кэширование, пагинацию и фильтрацию
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Rate limiting (мягкий лимит для внутренних запросов)
|
||||||
|
try {
|
||||||
|
const identifier = getRateLimitIdentifier(request);
|
||||||
|
const rateLimitResult = rateLimit(identifier, 200, 60000); // 200 запросов в минуту
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
// Не блокируем запросы, только логируем
|
||||||
|
logWarn('Rate limit warning for /api/aircraft', { component: 'api', action: 'rate-limit' });
|
||||||
|
}
|
||||||
|
} catch (rateLimitError) {
|
||||||
|
// Игнорируем ошибки rate limiting, продолжаем выполнение
|
||||||
|
logWarn('Rate limit check failed, continuing', {
|
||||||
|
component: 'api',
|
||||||
|
action: 'rate-limit',
|
||||||
|
error: rateLimitError instanceof Error ? rateLimitError.message : String(rateLimitError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация параметров запроса
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
searchParams.forEach((value, key) => {
|
||||||
|
params[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const validatedParams = validateRequestParams(aircraftFiltersSchema, params);
|
||||||
|
const { page, limit, paginate: usePagination, status: validatedStatus } = validatedParams;
|
||||||
|
|
||||||
|
// Если нужна пагинация на сервере
|
||||||
|
if (usePagination && process.env.DB_HOST) {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (validatedStatus) {
|
||||||
|
filters.push(validatedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseQuery = validatedStatus
|
||||||
|
? 'SELECT * FROM aircraft WHERE status = $1'
|
||||||
|
: 'SELECT * FROM aircraft';
|
||||||
|
|
||||||
|
const result = await paginatedQuery(
|
||||||
|
baseQuery,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
filters,
|
||||||
|
'created_at DESC'
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(result, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение всех данных с кэшированием
|
||||||
|
const aircraft = await getCachedAircraft();
|
||||||
|
|
||||||
|
// Если запрос без пагинации (нет параметра paginate=true), возвращаем все данные как массив
|
||||||
|
// Это для обратной совместимости с компонентами, которые ожидают массив
|
||||||
|
if (!usePagination) {
|
||||||
|
return NextResponse.json(aircraft, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Клиентская пагинация (если не используется серверная)
|
||||||
|
const startIndex = (page - 1) * limit;
|
||||||
|
const endIndex = startIndex + limit;
|
||||||
|
const paginatedAircraft = aircraft.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
data: paginatedAircraft,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total: aircraft.length,
|
||||||
|
totalPages: Math.ceil(aircraft.length / limit),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: '/api/aircraft',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/api/analytics/aircraft/route.ts
Normal file
58
app/api/analytics/aircraft/route.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAircraftAnalytics } from '@/lib/analytics/clickhouse-client';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const rateLimitResult = rateLimit(getRateLimitIdentifier(request));
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Слишком много запросов' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const dateFrom = searchParams.get('dateFrom');
|
||||||
|
const dateTo = searchParams.get('dateTo');
|
||||||
|
const operator = searchParams.get('operator');
|
||||||
|
|
||||||
|
const filters: any = {};
|
||||||
|
if (dateFrom) {
|
||||||
|
filters.dateFrom = dateFrom;
|
||||||
|
}
|
||||||
|
if (dateTo) {
|
||||||
|
filters.dateTo = dateTo;
|
||||||
|
}
|
||||||
|
if (operator) {
|
||||||
|
filters.operator = operator;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем аналитику из ClickHouse
|
||||||
|
const analytics = await getAircraftAnalytics(filters);
|
||||||
|
|
||||||
|
// Агрегируем результаты
|
||||||
|
const total = analytics.reduce((sum, item) => sum + (item.total || 0), 0);
|
||||||
|
const active = analytics.reduce((sum, item) => sum + (item.active || 0), 0);
|
||||||
|
const maintenance = analytics.reduce((sum, item) => sum + (item.maintenance || 0), 0);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
total,
|
||||||
|
active,
|
||||||
|
maintenance,
|
||||||
|
byOperator: analytics.map(item => ({
|
||||||
|
operator: item.operator,
|
||||||
|
count: item.total,
|
||||||
|
avgFlightHours: item.avgFlightHours,
|
||||||
|
maxFlightHours: item.maxFlightHours,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: '/api/analytics/aircraft',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/api/analytics/route.ts
Normal file
68
app/api/analytics/route.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getTimeSeriesData, comparePeriods, forecast, getUserActivityStats } from '@/lib/analytics/analytics-service';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/analytics - Получение аналитических данных
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Rate limiting
|
||||||
|
const identifier = getRateLimitIdentifier(request);
|
||||||
|
const rateLimitResult = await rateLimit(identifier);
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Слишком много запросов' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const type = searchParams.get('type');
|
||||||
|
const resourceType = searchParams.get('resourceType') || 'aircraft';
|
||||||
|
|
||||||
|
if (type === 'timeseries') {
|
||||||
|
const startDate = new Date(searchParams.get('startDate') || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000));
|
||||||
|
const endDate = new Date(searchParams.get('endDate') || new Date());
|
||||||
|
const groupBy = (searchParams.get('groupBy') || 'day') as 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
const data = await getTimeSeriesData(resourceType, startDate, endDate, groupBy);
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'compare') {
|
||||||
|
const currentStart = new Date(searchParams.get('currentStart') || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000));
|
||||||
|
const currentEnd = new Date(searchParams.get('currentEnd') || new Date());
|
||||||
|
const previousStart = new Date(searchParams.get('previousStart') || new Date(Date.now() - 60 * 24 * 60 * 60 * 1000));
|
||||||
|
const previousEnd = new Date(searchParams.get('previousEnd') || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000));
|
||||||
|
|
||||||
|
const data = await comparePeriods(resourceType, currentStart, currentEnd, previousStart, previousEnd);
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'forecast') {
|
||||||
|
const days = parseInt(searchParams.get('days') || '30');
|
||||||
|
const data = await forecast(resourceType, days);
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'activity') {
|
||||||
|
const startDate = new Date(searchParams.get('startDate') || new Date(Date.now() - 30 * 24 * 60 * 60 * 1000));
|
||||||
|
const endDate = new Date(searchParams.get('endDate') || new Date());
|
||||||
|
const stats = await getUserActivityStats(startDate, endDate);
|
||||||
|
return NextResponse.json({ stats });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Неверный тип запроса' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: '/api/analytics',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/api/audit/[resourceType]/[resourceId]/route.ts
Normal file
41
app/api/audit/[resourceType]/[resourceId]/route.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAuditHistory } from '@/lib/audit/audit-service';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/audit/[resourceType]/[resourceId] - История изменений ресурса
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { resourceType: string; resourceId: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Rate limiting
|
||||||
|
const identifier = getRateLimitIdentifier(request);
|
||||||
|
const rateLimitResult = rateLimit(identifier);
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Слишком много запросов' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '50');
|
||||||
|
|
||||||
|
const history = await getAuditHistory(
|
||||||
|
params.resourceType,
|
||||||
|
params.resourceId,
|
||||||
|
limit
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ history });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: `/api/audit/${params.resourceType}/${params.resourceId}`,
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/api/audit/rollback/route.ts
Normal file
54
app/api/audit/rollback/route.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { rollbackChange } from '@/lib/audit/audit-service';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
import { logSecurity } from '@/lib/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/audit/rollback - Откат изменений
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Rate limiting
|
||||||
|
const identifier = getRateLimitIdentifier(request);
|
||||||
|
const rateLimitResult = rateLimit(identifier);
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Слишком много запросов' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { auditLogId } = body;
|
||||||
|
|
||||||
|
if (!auditLogId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Не указан ID записи аудита' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логируем попытку отката
|
||||||
|
logSecurity('Попытка отката изменений', {
|
||||||
|
auditLogId,
|
||||||
|
ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const success = await rollbackChange(auditLogId);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return NextResponse.json({ success: true, message: 'Изменения успешно откачены' });
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Не удалось откатить изменения' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || 'Ошибка при откате изменений' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/api/audit/route.ts
Normal file
68
app/api/audit/route.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { searchAuditLogs, exportAuditLogs, AuditSearchFilters } from '@/lib/audit/audit-service';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/audit - Поиск записей аудита
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Rate limiting
|
||||||
|
const identifier = getRateLimitIdentifier(request);
|
||||||
|
const rateLimitResult = rateLimit(identifier);
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Слишком много запросов' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const filters: AuditSearchFilters = {
|
||||||
|
userId: searchParams.get('userId') || undefined,
|
||||||
|
action: searchParams.get('action') || undefined,
|
||||||
|
resourceType: searchParams.get('resourceType') || undefined,
|
||||||
|
resourceId: searchParams.get('resourceId') || undefined,
|
||||||
|
startDate: searchParams.get('startDate') ? new Date(searchParams.get('startDate')!) : undefined,
|
||||||
|
endDate: searchParams.get('endDate') ? new Date(searchParams.get('endDate')!) : undefined,
|
||||||
|
ipAddress: searchParams.get('ipAddress') || undefined,
|
||||||
|
search: searchParams.get('search') || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '100');
|
||||||
|
const offset = parseInt(searchParams.get('offset') || '0');
|
||||||
|
const format = searchParams.get('format') as 'json' | 'csv' | null;
|
||||||
|
|
||||||
|
// Если запрошен экспорт
|
||||||
|
if (format) {
|
||||||
|
const exported = await exportAuditLogs(filters, format);
|
||||||
|
const contentType = format === 'json' ? 'application/json' : 'text/csv';
|
||||||
|
const filename = `audit_logs_${new Date().toISOString().split('T')[0]}.${format}`;
|
||||||
|
|
||||||
|
return new NextResponse(exported, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обычный поиск
|
||||||
|
const result = await searchAuditLogs(filters, limit, offset);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
logs: result.logs,
|
||||||
|
total: result.total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: '/api/audit',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/api/audits/route.ts
Normal file
47
app/api/audits/route.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCachedAudits } from '@/lib/api/cached-api';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Route для получения списка аудитов
|
||||||
|
* Поддерживает кэширование и фильтрацию
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Rate limiting (мягкий лимит)
|
||||||
|
try {
|
||||||
|
const identifier = getRateLimitIdentifier(request);
|
||||||
|
const rateLimitResult = rateLimit(identifier, 200, 60000);
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
console.warn('Rate limit warning for /api/audits');
|
||||||
|
}
|
||||||
|
} catch (rateLimitError) {
|
||||||
|
console.warn('Rate limit check failed, continuing:', rateLimitError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const filters = {
|
||||||
|
organizationId: searchParams.get('organizationId') || undefined,
|
||||||
|
status: searchParams.get('status') || undefined,
|
||||||
|
dateFrom: searchParams.get('dateFrom') || undefined,
|
||||||
|
dateTo: searchParams.get('dateTo') || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const audits = await getCachedAudits(filters);
|
||||||
|
|
||||||
|
return NextResponse.json(audits, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'public, s-maxage=180, stale-while-revalidate=300',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: '/api/audits',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
4
app/api/batch/route.ts
Normal file
4
app/api/batch/route.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { POST as handleBatch } from '@/lib/api/batch';
|
||||||
|
|
||||||
|
export { handleBatch as POST };
|
||||||
37
app/api/health/route.ts
Normal file
37
app/api/health/route.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { checkHealth } from '@/lib/monitoring/health';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const readiness = searchParams.get('readiness') === 'true';
|
||||||
|
|
||||||
|
const health = await checkHealth();
|
||||||
|
|
||||||
|
// Для readiness probe проверяем, что система готова принимать трафик
|
||||||
|
if (readiness) {
|
||||||
|
// Readiness означает, что система может обрабатывать запросы
|
||||||
|
// Проверяем, что хотя бы основные компоненты работают
|
||||||
|
const isReady =
|
||||||
|
health.status === 'healthy' ||
|
||||||
|
(health.status === 'degraded' &&
|
||||||
|
health.checks.database.status === 'ok'); // БД должна быть доступна
|
||||||
|
|
||||||
|
if (!isReady) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: 'not ready',
|
||||||
|
message: 'System is not ready to accept traffic',
|
||||||
|
checks: health.checks,
|
||||||
|
},
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для liveness probe возвращаем общий статус
|
||||||
|
const statusCode = health.status === 'healthy' ? 200 :
|
||||||
|
health.status === 'degraded' ? 200 : 503;
|
||||||
|
|
||||||
|
return NextResponse.json(health, { status: statusCode });
|
||||||
|
}
|
||||||
154
app/api/jira-tasks/route.ts
Normal file
154
app/api/jira-tasks/route.ts
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* API endpoint для работы с задачами Jira
|
||||||
|
* Данные импортируются из CSV файлов в папке "новая папка"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { pool } from '@/lib/database/connection';
|
||||||
|
import { getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Rate limiting
|
||||||
|
const rateLimitResult = rateLimit(getRateLimitIdentifier(request));
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Слишком много запросов' },
|
||||||
|
{ status: 429, headers: { 'Retry-After': String(rateLimitResult.resetTime) } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const type = searchParams.get('type'); // 'epic', 'story', 'subtask', 'all'
|
||||||
|
const epicId = searchParams.get('epic_id');
|
||||||
|
const storyId = searchParams.get('story_id');
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (type === 'epic' || !type) {
|
||||||
|
// Получаем все эпики
|
||||||
|
const epicsResult = await client.query(
|
||||||
|
`SELECT
|
||||||
|
issue_id as "issueId",
|
||||||
|
summary,
|
||||||
|
description,
|
||||||
|
priority,
|
||||||
|
components,
|
||||||
|
labels,
|
||||||
|
created_at as "createdAt",
|
||||||
|
updated_at as "updatedAt"
|
||||||
|
FROM jira_epics
|
||||||
|
ORDER BY
|
||||||
|
CASE priority
|
||||||
|
WHEN 'High' THEN 1
|
||||||
|
WHEN 'Medium' THEN 2
|
||||||
|
WHEN 'Low' THEN 3
|
||||||
|
ELSE 4
|
||||||
|
END,
|
||||||
|
created_at DESC`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Для каждого эпика получаем связанные истории
|
||||||
|
for (const epic of epicsResult.rows) {
|
||||||
|
const storiesResult = await client.query(
|
||||||
|
`SELECT
|
||||||
|
issue_id as "issueId",
|
||||||
|
summary,
|
||||||
|
description,
|
||||||
|
priority,
|
||||||
|
story_points as "storyPoints",
|
||||||
|
components,
|
||||||
|
labels,
|
||||||
|
acceptance_criteria as "acceptanceCriteria",
|
||||||
|
created_at as "createdAt"
|
||||||
|
FROM jira_stories
|
||||||
|
WHERE parent_epic_id = $1
|
||||||
|
ORDER BY priority DESC, story_points DESC`,
|
||||||
|
[epic.issueId]
|
||||||
|
);
|
||||||
|
|
||||||
|
epic.stories = storiesResult.rows;
|
||||||
|
|
||||||
|
// Для каждой истории получаем подзадачи
|
||||||
|
for (const story of epic.stories) {
|
||||||
|
const subtasksResult = await client.query(
|
||||||
|
`SELECT
|
||||||
|
issue_id as "issueId",
|
||||||
|
summary,
|
||||||
|
description,
|
||||||
|
priority,
|
||||||
|
components,
|
||||||
|
labels,
|
||||||
|
created_at as "createdAt"
|
||||||
|
FROM jira_subtasks
|
||||||
|
WHERE parent_story_id = $1
|
||||||
|
ORDER BY priority DESC`,
|
||||||
|
[story.issueId]
|
||||||
|
);
|
||||||
|
|
||||||
|
story.subtasks = subtasksResult.rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = epicsResult.rows;
|
||||||
|
} else if (type === 'story') {
|
||||||
|
const query = epicId
|
||||||
|
? `SELECT * FROM jira_stories WHERE parent_epic_id = $1 ORDER BY priority DESC, story_points DESC`
|
||||||
|
: `SELECT * FROM jira_stories ORDER BY priority DESC, story_points DESC`;
|
||||||
|
const params = epicId ? [epicId] : [];
|
||||||
|
result = (await client.query(query, params)).rows;
|
||||||
|
} else if (type === 'subtask') {
|
||||||
|
const query = storyId
|
||||||
|
? `SELECT * FROM jira_subtasks WHERE parent_story_id = $1 ORDER BY priority DESC`
|
||||||
|
: `SELECT * FROM jira_subtasks ORDER BY priority DESC`;
|
||||||
|
const params = storyId ? [storyId] : [];
|
||||||
|
result = (await client.query(query, params)).rows;
|
||||||
|
} else {
|
||||||
|
// Все задачи
|
||||||
|
const [epics, stories, subtasks] = await Promise.all([
|
||||||
|
client.query('SELECT * FROM jira_epics ORDER BY priority DESC'),
|
||||||
|
client.query('SELECT * FROM jira_stories ORDER BY priority DESC, story_points DESC'),
|
||||||
|
client.query('SELECT * FROM jira_subtasks ORDER BY priority DESC'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
result = {
|
||||||
|
epics: epics.rows,
|
||||||
|
stories: stories.rows,
|
||||||
|
subtasks: subtasks.rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем зависимости
|
||||||
|
const dependenciesResult = await client.query(
|
||||||
|
`SELECT
|
||||||
|
from_issue_id as "fromIssueId",
|
||||||
|
to_issue_id as "toIssueId",
|
||||||
|
link_type as "linkType"
|
||||||
|
FROM jira_task_dependencies
|
||||||
|
ORDER BY created_at DESC`
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
data: result,
|
||||||
|
dependencies: dependenciesResult.rows,
|
||||||
|
meta: {
|
||||||
|
total: Array.isArray(result) ? result.length : Object.keys(result).length,
|
||||||
|
type: type || 'epic',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: '/api/jira-tasks',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/api/knowledge/insights/route.ts
Normal file
14
app/api/knowledge/insights/route.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
return NextResponse.json({
|
||||||
|
insights: [],
|
||||||
|
query: body.query || '',
|
||||||
|
message: 'Knowledge insights stub — connect to AI service for real data'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ insights: [], message: 'No query provided' });
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/api/knowledge/recommendations/route.ts
Normal file
14
app/api/knowledge/recommendations/route.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
return NextResponse.json({
|
||||||
|
recommendations: [],
|
||||||
|
context: body.context || '',
|
||||||
|
message: 'Recommendations stub — connect to AI service for real data'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ recommendations: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/api/knowledge/search/route.ts
Normal file
20
app/api/knowledge/search/route.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
return NextResponse.json({
|
||||||
|
results: [],
|
||||||
|
query: body.query || '',
|
||||||
|
total: 0,
|
||||||
|
message: 'Search stub — connect to search service for real data'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ results: [], total: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const q = searchParams.get('q') || '';
|
||||||
|
return NextResponse.json({ results: [], query: q, total: 0 });
|
||||||
|
}
|
||||||
11
app/api/log-error/route.ts
Normal file
11
app/api/log-error/route.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
console.error('[CLIENT ERROR]', JSON.stringify(body, null, 2));
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ ok: false }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/api/metrics/route.ts
Normal file
81
app/api/metrics/route.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { metrics } from '@/lib/monitoring/metrics';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API endpoint для получения метрик
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Rate limiting
|
||||||
|
const rateLimitResult = rateLimit(getRateLimitIdentifier(request), 100, 60000);
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Слишком много запросов' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const type = searchParams.get('type') || 'all';
|
||||||
|
const endpoint = searchParams.get('endpoint');
|
||||||
|
const startTime = searchParams.get('startTime');
|
||||||
|
const endTime = searchParams.get('endTime');
|
||||||
|
const metricName = searchParams.get('metricName');
|
||||||
|
const periodMs = parseInt(searchParams.get('periodMs') || '60000');
|
||||||
|
|
||||||
|
if (type === 'performance') {
|
||||||
|
// Получить статистику по метрике производительности
|
||||||
|
const metricKey = endpoint ? `performance.${endpoint}` : 'performance';
|
||||||
|
const stats = metrics.getStats(metricKey, periodMs);
|
||||||
|
return NextResponse.json({
|
||||||
|
type: 'performance',
|
||||||
|
stats,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'performance-details') {
|
||||||
|
// Получить детальные метрики производительности
|
||||||
|
const metricKey = endpoint ? `performance.${endpoint}` : 'performance';
|
||||||
|
const metricsData = metrics.getMetrics(
|
||||||
|
metricKey,
|
||||||
|
startTime ? new Date(startTime) : undefined,
|
||||||
|
endTime ? new Date(endTime) : undefined
|
||||||
|
);
|
||||||
|
return NextResponse.json({
|
||||||
|
type: 'performance-details',
|
||||||
|
metrics: metricsData,
|
||||||
|
count: metricsData.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metricName) {
|
||||||
|
// Получить статистику по конкретной метрике
|
||||||
|
const stats = metrics.getStats(metricName, periodMs);
|
||||||
|
return NextResponse.json({
|
||||||
|
metric: metricName,
|
||||||
|
stats,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Все метрики
|
||||||
|
const allMetrics = metrics.getMetrics(
|
||||||
|
undefined,
|
||||||
|
startTime ? new Date(startTime) : undefined,
|
||||||
|
endTime ? new Date(endTime) : undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
type: 'all',
|
||||||
|
metrics: allMetrics,
|
||||||
|
count: allMetrics.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: '/api/metrics',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/api/monitoring/metrics/route.ts
Normal file
67
app/api/monitoring/metrics/route.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { metrics } from '@/lib/monitoring/metrics';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const rateLimitResult = rateLimit(getRateLimitIdentifier(request), 100, 60000);
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Слишком много запросов' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const metricName = searchParams.get('name');
|
||||||
|
const level = searchParams.get('level') as 'info' | 'warning' | 'error' | 'critical' | null;
|
||||||
|
const component = searchParams.get('component');
|
||||||
|
const periodMs = parseInt(searchParams.get('periodMs') || '60000');
|
||||||
|
|
||||||
|
// Получить метрики
|
||||||
|
if (metricName) {
|
||||||
|
const stats = metrics.getStats(metricName, periodMs);
|
||||||
|
return NextResponse.json({
|
||||||
|
metric: metricName,
|
||||||
|
stats,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить алерты
|
||||||
|
if (level || component) {
|
||||||
|
const alerts = metrics.getAlerts(level || undefined, component || undefined);
|
||||||
|
return NextResponse.json({
|
||||||
|
alerts: alerts.slice(-100), // Последние 100 алертов
|
||||||
|
count: alerts.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Общая статистика
|
||||||
|
const recentMetrics = metrics.getMetrics(undefined, new Date(Date.now() - periodMs));
|
||||||
|
const recentAlerts = metrics.getAlerts();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
metrics: {
|
||||||
|
total: recentMetrics.length,
|
||||||
|
byName: recentMetrics.reduce((acc, m) => {
|
||||||
|
acc[m.name] = (acc[m.name] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>),
|
||||||
|
},
|
||||||
|
alerts: {
|
||||||
|
total: recentAlerts.length,
|
||||||
|
byLevel: recentAlerts.reduce((acc, a) => {
|
||||||
|
acc[a.level] = (acc[a.level] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>),
|
||||||
|
recent: recentAlerts.slice(-10),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/api/notifications/[id]/read/route.ts
Normal file
35
app/api/notifications/[id]/read/route.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { markNotificationAsRead } from '@/lib/notifications/notification-service';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/notifications/[id]/read - Отметить уведомление как прочитанное
|
||||||
|
*/
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Rate limiting
|
||||||
|
const identifier = getRateLimitIdentifier(request);
|
||||||
|
const rateLimitResult = rateLimit(identifier);
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Слишком много запросов' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = request.headers.get('x-user-id') || undefined;
|
||||||
|
await markNotificationAsRead(params.id, userId);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: `/api/notifications/${params.id}/read`,
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/api/notifications/email/route.ts
Normal file
56
app/api/notifications/email/route.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { sendCriticalRiskEmail, sendUpcomingAuditEmail } from '@/lib/notifications/email-service';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/notifications/email - Отправка email уведомления
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Rate limiting
|
||||||
|
const identifier = getRateLimitIdentifier(request);
|
||||||
|
const rateLimitResult = rateLimit(identifier);
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Слишком много запросов' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { type, ...data } = body;
|
||||||
|
|
||||||
|
let success = false;
|
||||||
|
|
||||||
|
if (type === 'critical_risk') {
|
||||||
|
success = await sendCriticalRiskEmail(
|
||||||
|
data.userEmail,
|
||||||
|
data.riskTitle,
|
||||||
|
data.aircraftRegistration,
|
||||||
|
data.riskId
|
||||||
|
);
|
||||||
|
} else if (type === 'upcoming_audit') {
|
||||||
|
success = await sendUpcomingAuditEmail(
|
||||||
|
data.userEmail,
|
||||||
|
data.auditType,
|
||||||
|
data.organizationName,
|
||||||
|
data.auditDate,
|
||||||
|
data.daysUntil
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Неверный тип уведомления' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success });
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: '/api/notifications/email',
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/api/notifications/route.ts
Normal file
41
app/api/notifications/route.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getAllNotifications } from '@/lib/notifications/notification-service';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/notifications - Получение всех уведомлений
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Rate limiting
|
||||||
|
const identifier = getRateLimitIdentifier(request);
|
||||||
|
const rateLimitResult = rateLimit(identifier);
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Слишком много запросов' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifications = await getAllNotifications();
|
||||||
|
|
||||||
|
// Преобразуем Date в строки для JSON
|
||||||
|
const serializedNotifications = notifications.map(n => ({
|
||||||
|
...n,
|
||||||
|
createdAt: n.createdAt instanceof Date ? n.createdAt.toISOString() : n.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
notifications: serializedNotifications,
|
||||||
|
count: serializedNotifications.length,
|
||||||
|
unreadCount: serializedNotifications.filter(n => !n.read).length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: '/api/notifications',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
290
app/api/openapi/route.ts
Normal file
290
app/api/openapi/route.ts
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI спецификация для AI endpoints
|
||||||
|
*/
|
||||||
|
const openApiSpec = {
|
||||||
|
openapi: '3.0.0',
|
||||||
|
info: {
|
||||||
|
title: 'KLG ASUTK AI API',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'API для взаимодействия с AI-системой контроля лётной годности воздушных судов',
|
||||||
|
contact: {
|
||||||
|
name: 'API Support',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: process.env.NEXT_PUBLIC_API_URL || '',
|
||||||
|
description: 'Production server',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
paths: {
|
||||||
|
'/api/ai/agent': {
|
||||||
|
post: {
|
||||||
|
summary: 'Обработка естественного языка запроса',
|
||||||
|
description: 'Обрабатывает запрос на естественном языке и возвращает ответ с reasoning',
|
||||||
|
tags: ['AI Agent'],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['query'],
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Запрос на естественном языке',
|
||||||
|
example: 'Покажи все активные воздушные суда',
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['copilot', 'autonomous'],
|
||||||
|
default: 'copilot',
|
||||||
|
description: 'Режим работы агента',
|
||||||
|
},
|
||||||
|
context: {
|
||||||
|
type: 'object',
|
||||||
|
description: 'Дополнительный контекст для запроса',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
'200': {
|
||||||
|
description: 'Успешный ответ',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
answer: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Ответ на запрос',
|
||||||
|
},
|
||||||
|
reasoning: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
description: 'Цепочка рассуждений',
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
type: { type: 'string' },
|
||||||
|
description: { type: 'string' },
|
||||||
|
executed: { type: 'boolean' },
|
||||||
|
result: { type: 'object' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
confidence: {
|
||||||
|
type: 'number',
|
||||||
|
minimum: 0,
|
||||||
|
maximum: 1,
|
||||||
|
description: 'Уверенность в ответе',
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['copilot', 'autonomous'],
|
||||||
|
},
|
||||||
|
intent: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
intent: { type: 'string' },
|
||||||
|
confidence: { type: 'number' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'400': {
|
||||||
|
description: 'Неверный запрос',
|
||||||
|
},
|
||||||
|
'429': {
|
||||||
|
description: 'Превышен лимит запросов',
|
||||||
|
},
|
||||||
|
'500': {
|
||||||
|
description: 'Внутренняя ошибка сервера',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/knowledge/graph': {
|
||||||
|
get: {
|
||||||
|
summary: 'Получение Knowledge Graph',
|
||||||
|
description: 'Возвращает граф знаний или результаты поиска в графе',
|
||||||
|
tags: ['Knowledge Graph'],
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: 'query',
|
||||||
|
in: 'query',
|
||||||
|
description: 'Поисковый запрос (опционально)',
|
||||||
|
required: false,
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'format',
|
||||||
|
in: 'query',
|
||||||
|
description: 'Формат ответа',
|
||||||
|
required: false,
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['json', 'visualization'],
|
||||||
|
default: 'json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
'200': {
|
||||||
|
description: 'Успешный ответ',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
nodes: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
type: { type: 'string' },
|
||||||
|
label: { type: 'string' },
|
||||||
|
properties: { type: 'object' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
edges: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string' },
|
||||||
|
source: { type: 'string' },
|
||||||
|
target: { type: 'string' },
|
||||||
|
type: { type: 'string' },
|
||||||
|
weight: { type: 'number' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'429': {
|
||||||
|
description: 'Превышен лимит запросов',
|
||||||
|
},
|
||||||
|
'500': {
|
||||||
|
description: 'Внутренняя ошибка сервера',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/knowledge/search': {
|
||||||
|
post: {
|
||||||
|
summary: 'Семантический поиск в базе знаний',
|
||||||
|
description: 'Выполняет семантический поиск по базе знаний',
|
||||||
|
tags: ['Knowledge Base'],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['query'],
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Поисковый запрос',
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'number',
|
||||||
|
default: 10,
|
||||||
|
description: 'Максимальное количество результатов',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
'200': {
|
||||||
|
description: 'Успешный ответ',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/knowledge/insights': {
|
||||||
|
get: {
|
||||||
|
summary: 'Получение инсайтов',
|
||||||
|
description: 'Генерирует инсайты на основе данных',
|
||||||
|
tags: ['Knowledge Base'],
|
||||||
|
responses: {
|
||||||
|
'200': {
|
||||||
|
description: 'Успешный ответ',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'/api/knowledge/recommendations': {
|
||||||
|
get: {
|
||||||
|
summary: 'Получение рекомендаций',
|
||||||
|
description: 'Генерирует рекомендации на основе данных',
|
||||||
|
tags: ['Knowledge Base'],
|
||||||
|
responses: {
|
||||||
|
'200': {
|
||||||
|
description: 'Успешный ответ',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
Error: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
error: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Сообщение об ошибке',
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Код ошибки',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: [
|
||||||
|
{
|
||||||
|
name: 'AI Agent',
|
||||||
|
description: 'Endpoints для взаимодействия с автономным агентом',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Knowledge Graph',
|
||||||
|
description: 'Endpoints для работы с графом знаний',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Knowledge Base',
|
||||||
|
description: 'Endpoints для работы с базой знаний',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json(openApiSpec);
|
||||||
|
}
|
||||||
39
app/api/organizations/route.ts
Normal file
39
app/api/organizations/route.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCachedOrganizations } from '@/lib/api/cached-api';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Route для получения списка организаций
|
||||||
|
* Поддерживает кэширование
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Rate limiting (мягкий лимит)
|
||||||
|
try {
|
||||||
|
const identifier = getRateLimitIdentifier(request);
|
||||||
|
const rateLimitResult = rateLimit(identifier, 200, 60000);
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
console.warn('Rate limit warning for /api/organizations');
|
||||||
|
}
|
||||||
|
} catch (rateLimitError) {
|
||||||
|
console.warn('Rate limit check failed, continuing:', rateLimitError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizations = await getCachedOrganizations();
|
||||||
|
|
||||||
|
return NextResponse.json(organizations, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'public, s-maxage=600, stale-while-revalidate=1200',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: '/api/organizations',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/api/parquet/download/route.ts
Normal file
41
app/api/parquet/download/route.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getSignedUrlForFile } from '@/lib/storage/s3-client';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const rateLimitResult = rateLimit(getRateLimitIdentifier(request));
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Слишком много запросов' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const key = searchParams.get('key');
|
||||||
|
const expiresIn = parseInt(searchParams.get('expiresIn') || '3600');
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Параметр key обязателен' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерируем подписанный URL
|
||||||
|
const signedUrl = await getSignedUrlForFile(key, expiresIn);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
url: signedUrl,
|
||||||
|
expiresIn,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: '/api/parquet/download',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/api/parquet/upload/route.ts
Normal file
46
app/api/parquet/upload/route.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { uploadParquetFile } from '@/lib/storage/s3-client';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const rateLimitResult = rateLimit(getRateLimitIdentifier(request));
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Слишком много запросов' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File;
|
||||||
|
const key = formData.get('key') as string;
|
||||||
|
|
||||||
|
if (!file || !key) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Файл и ключ обязательны' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Конвертируем File в Buffer
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
// Загружаем в S3
|
||||||
|
const s3Key = await uploadParquetFile(key, buffer);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
key: s3Key,
|
||||||
|
size: buffer.length,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: '/api/parquet/upload',
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
266
app/api/regulations/route.ts
Normal file
266
app/api/regulations/route.ts
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { RegulationDocument, chicagoConventionAnnexes } from '@/lib/regulations';
|
||||||
|
|
||||||
|
// В реальном приложении здесь будет загрузка из базы данных или внешних источников
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const source = searchParams.get('source');
|
||||||
|
const type = searchParams.get('type');
|
||||||
|
const search = searchParams.get('search');
|
||||||
|
|
||||||
|
// Мок-данные для демонстрации
|
||||||
|
// В реальном приложении здесь будет запрос к базе данных
|
||||||
|
let documents: RegulationDocument[] = [
|
||||||
|
{
|
||||||
|
id: 'chicago-convention',
|
||||||
|
title: 'Конвенция о международной гражданской авиации (Chicago Convention)',
|
||||||
|
source: 'ICAO',
|
||||||
|
type: 'convention',
|
||||||
|
category: 'Основополагающий документ',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'Конвенция о международной гражданской авиации была подписана в Чикаго 7 декабря 1944 года. Это основополагающий документ, который регулирует международную гражданскую авиацию. Конвенция включает 19 приложений, каждое из которых охватывает разные аспекты авиации, такие как безопасность, эксплуатация воздушных судов, сертификация, управление воздушным движением и др.',
|
||||||
|
url: 'https://www.icao.int/publications/pages/doc7300.aspx',
|
||||||
|
sections: chicagoConventionAnnexes.map(annex => ({
|
||||||
|
id: annex.id,
|
||||||
|
title: annex.title,
|
||||||
|
content: `Содержание ${annex.title}: ${annex.category}`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'icao-annex-8',
|
||||||
|
title: 'ICAO Annex 8 - Airworthiness of Aircraft',
|
||||||
|
source: 'ICAO',
|
||||||
|
type: 'annex',
|
||||||
|
category: 'Лётная годность воздушных судов',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'Annex 8 устанавливает международные стандарты и рекомендуемую практику для лётной годности воздушных судов. Документ определяет минимальные требования к конструкции, производству и эксплуатации воздушных судов.',
|
||||||
|
url: 'https://www.icao.int/publications/pages/doc7300.aspx',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'easa-part-21',
|
||||||
|
title: 'EASA Part-21 - Certification of aircraft and related products',
|
||||||
|
source: 'EASA',
|
||||||
|
type: 'regulation',
|
||||||
|
category: 'Сертификация воздушных судов',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'EASA Part-21 устанавливает требования к сертификации воздушных судов и связанных продуктов, частей и принадлежностей, а также к проектированию и производству организаций.',
|
||||||
|
url: 'https://www.easa.europa.eu/en/regulations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'easa-part-m',
|
||||||
|
title: 'EASA Part-M - Continuing Airworthiness',
|
||||||
|
source: 'EASA',
|
||||||
|
type: 'regulation',
|
||||||
|
category: 'Поддержание лётной годности',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'EASA Part-M устанавливает требования к поддержанию лётной годности воздушных судов, включая техническое обслуживание, ремонт и модификации.',
|
||||||
|
url: 'https://www.easa.europa.eu/en/regulations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'faa-part-91',
|
||||||
|
title: 'FAA Part 91 - General Operating and Flight Rules',
|
||||||
|
source: 'FAA',
|
||||||
|
type: 'regulation',
|
||||||
|
category: 'Общие правила эксплуатации и полетов',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'FAA Part 91 устанавливает общие правила эксплуатации и полетов для гражданских воздушных судов в США. Включает требования к сертификации, эксплуатации и техническому обслуживанию.',
|
||||||
|
url: 'https://www.faa.gov/regulations_policies',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'faa-part-43',
|
||||||
|
title: 'FAA Part 43 - Maintenance, Preventive Maintenance, Rebuilding, and Alteration',
|
||||||
|
source: 'FAA',
|
||||||
|
type: 'regulation',
|
||||||
|
category: 'Техническое обслуживание',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'FAA Part 43 устанавливает правила для технического обслуживания, профилактического обслуживания, восстановления и модификации воздушных судов.',
|
||||||
|
url: 'https://www.faa.gov/regulations_policies',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mak-standards',
|
||||||
|
title: 'МАК - Стандарты и правила авиационной безопасности',
|
||||||
|
source: 'MAK',
|
||||||
|
type: 'regulation',
|
||||||
|
category: 'Стандарты безопасности',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'Межгосударственный авиационный комитет (МАК) разрабатывает и внедряет стандарты и правила авиационной безопасности для стран СНГ. Включает требования к лётной годности, сертификации и эксплуатации воздушных судов.',
|
||||||
|
url: 'http://www.mak.ru',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mak-certification',
|
||||||
|
title: 'МАК - Правила сертификации воздушных судов',
|
||||||
|
source: 'MAK',
|
||||||
|
type: 'regulation',
|
||||||
|
category: 'Сертификация',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'Правила сертификации воздушных судов МАК устанавливают требования к сертификации типов воздушных судов, их частей и принадлежностей в странах СНГ.',
|
||||||
|
url: 'http://www.mak.ru',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'armac-fap-128',
|
||||||
|
title: 'АРМАК - ФАП-128. Требования к лётной годности воздушных судов',
|
||||||
|
source: 'ARMAC',
|
||||||
|
type: 'rule',
|
||||||
|
category: 'Лётная годность',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'Федеральные авиационные правила устанавливают требования к лётной годности гражданских воздушных судов, их частей и принадлежностей. Определяют минимальные стандарты безопасности для эксплуатации воздушных судов.',
|
||||||
|
url: 'https://favt.gov.ru',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'armac-fap-145',
|
||||||
|
title: 'АРМАК - ФАП-145. Требования к организациям по техническому обслуживанию',
|
||||||
|
source: 'ARMAC',
|
||||||
|
type: 'rule',
|
||||||
|
category: 'Техническое обслуживание',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'ФАП-145 устанавливает требования к организациям, выполняющим техническое обслуживание и ремонт воздушных судов. Определяет стандарты качества и безопасности работ.',
|
||||||
|
url: 'https://favt.gov.ru',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'armac-fap-147',
|
||||||
|
title: 'АРМАК - ФАП-147. Требования к учебным организациям',
|
||||||
|
source: 'ARMAC',
|
||||||
|
type: 'rule',
|
||||||
|
category: 'Обучение',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'ФАП-147 устанавливает требования к учебным организациям, осуществляющим подготовку специалистов по техническому обслуживанию воздушных судов.',
|
||||||
|
url: 'https://favt.gov.ru',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'russian-air-code',
|
||||||
|
title: 'Воздушный кодекс Российской Федерации',
|
||||||
|
source: 'AIR_CODE',
|
||||||
|
type: 'code',
|
||||||
|
category: 'Федеральный закон',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'Воздушный кодекс РФ устанавливает правовые основы использования воздушного пространства Российской Федерации и деятельности в области авиации. Определяет права и обязанности участников авиационной деятельности.',
|
||||||
|
url: 'http://www.consultant.ru/document/cons_doc_LAW_137902/',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fap-128',
|
||||||
|
title: 'ФАП-128. Требования к лётной годности воздушных судов',
|
||||||
|
source: 'RUSSIAN_RULES',
|
||||||
|
type: 'rule',
|
||||||
|
category: 'Лётная годность',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'Федеральные авиационные правила устанавливают требования к лётной годности гражданских воздушных судов. Определяют минимальные стандарты безопасности для конструкции, производства и эксплуатации воздушных судов.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fap-145',
|
||||||
|
title: 'ФАП-145. Требования к организациям по техническому обслуживанию',
|
||||||
|
source: 'RUSSIAN_RULES',
|
||||||
|
type: 'rule',
|
||||||
|
category: 'Техническое обслуживание',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'ФАП-145 устанавливает требования к организациям, выполняющим техническое обслуживание, ремонт и модификацию воздушных судов. Определяет стандарты качества работ и квалификацию персонала.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fap-147',
|
||||||
|
title: 'ФАП-147. Требования к учебным организациям',
|
||||||
|
source: 'RUSSIAN_RULES',
|
||||||
|
type: 'rule',
|
||||||
|
category: 'Обучение',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'ФАП-147 устанавливает требования к учебным организациям, осуществляющим подготовку и переподготовку специалистов по техническому обслуживанию воздушных судов.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fap-21',
|
||||||
|
title: 'ФАП-21. Производство авиационной техники',
|
||||||
|
source: 'RUSSIAN_RULES',
|
||||||
|
type: 'rule',
|
||||||
|
category: 'Производство',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'ФАП-21 устанавливает требования к производству авиационной техники, включая воздушные суда, их части и принадлежности. Определяет стандарты качества производства.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fap-25',
|
||||||
|
title: 'ФАП-25. Нормы лётной годности самолётов транспортной категории',
|
||||||
|
source: 'RUSSIAN_RULES',
|
||||||
|
type: 'rule',
|
||||||
|
category: 'Лётная годность',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'ФАП-25 устанавливает нормы лётной годности для самолётов транспортной категории. Определяет минимальные требования к конструкции, характеристикам и эксплуатации.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fap-29',
|
||||||
|
title: 'ФАП-29. Нормы лётной годности винтокрылых аппаратов',
|
||||||
|
source: 'RUSSIAN_RULES',
|
||||||
|
type: 'rule',
|
||||||
|
category: 'Лётная годность',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'ФАП-29 устанавливает нормы лётной годности для винтокрылых аппаратов (вертолётов). Определяет требования к конструкции и эксплуатации вертолётов.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fap-39',
|
||||||
|
title: 'ФАП-39. Воздушная навигация',
|
||||||
|
source: 'RUSSIAN_RULES',
|
||||||
|
type: 'rule',
|
||||||
|
category: 'Навигация',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'ФАП-39 устанавливает требования к воздушной навигации, включая навигационное оборудование, процедуры и стандарты.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fap-50',
|
||||||
|
title: 'ФАП-50. Требования к аэродромам',
|
||||||
|
source: 'RUSSIAN_RULES',
|
||||||
|
type: 'rule',
|
||||||
|
category: 'Аэродромы',
|
||||||
|
version: '2024',
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
content: 'ФАП-50 устанавливает требования к аэродромам, включая инфраструктуру, оборудование и эксплуатацию.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Фильтрация по источнику
|
||||||
|
if (source) {
|
||||||
|
documents = documents.filter(doc => doc.source === source);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтрация по типу
|
||||||
|
if (type) {
|
||||||
|
documents = documents.filter(doc => doc.type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск
|
||||||
|
if (search) {
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
documents = documents.filter(doc =>
|
||||||
|
doc.title.toLowerCase().includes(searchLower) ||
|
||||||
|
doc.content.toLowerCase().includes(searchLower) ||
|
||||||
|
doc.category.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
documents,
|
||||||
|
total: documents.length,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Ошибка загрузки нормативных документов:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Ошибка при загрузке документов', message: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/api/regulations/update/route.ts
Normal file
43
app/api/regulations/update/route.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
// Эндпоинт для обновления нормативных документов
|
||||||
|
// Должен вызываться автоматически раз в месяц через cron job или scheduled task
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { source } = await request.json().catch(() => ({}));
|
||||||
|
|
||||||
|
console.log(`Начато обновление нормативных документов для источника: ${source || 'все'}`);
|
||||||
|
|
||||||
|
// В реальном приложении здесь будет:
|
||||||
|
// 1. Загрузка документов с официальных сайтов (ICAO, EASA, FAA, МАК, АРМАК)
|
||||||
|
// 2. Парсинг и извлечение актуальной информации
|
||||||
|
// 3. Сохранение в базу данных
|
||||||
|
// 4. Версионирование документов
|
||||||
|
|
||||||
|
const updateResults = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
sources: [
|
||||||
|
{ name: 'ICAO', status: 'updated', documents: 19 },
|
||||||
|
{ name: 'EASA', status: 'updated', documents: 15 },
|
||||||
|
{ name: 'FAA', status: 'updated', documents: 12 },
|
||||||
|
{ name: 'MAK', status: 'updated', documents: 8 },
|
||||||
|
{ name: 'ARMAC', status: 'updated', documents: 10 },
|
||||||
|
{ name: 'RUSSIAN_RULES', status: 'updated', documents: 25 },
|
||||||
|
{ name: 'AIR_CODE', status: 'updated', documents: 1 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Нормативные документы успешно обновлены',
|
||||||
|
results: updateResults,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Ошибка обновления нормативных документов:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Ошибка при обновлении документов', message: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/api/risks/route.ts
Normal file
46
app/api/risks/route.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCachedRisks } from '@/lib/api/cached-api';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Route для получения списка рисков
|
||||||
|
* Поддерживает кэширование и фильтрацию
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Rate limiting (мягкий лимит)
|
||||||
|
try {
|
||||||
|
const identifier = getRateLimitIdentifier(request);
|
||||||
|
const rateLimitResult = rateLimit(identifier, 200, 60000);
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
console.warn('Rate limit warning for /api/risks');
|
||||||
|
}
|
||||||
|
} catch (rateLimitError) {
|
||||||
|
console.warn('Rate limit check failed, continuing:', rateLimitError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const filters = {
|
||||||
|
level: searchParams.get('level') || undefined,
|
||||||
|
status: searchParams.get('status') || undefined,
|
||||||
|
aircraftId: searchParams.get('aircraftId') || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const risks = await getCachedRisks(filters);
|
||||||
|
|
||||||
|
return NextResponse.json(risks, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'public, s-maxage=180, stale-while-revalidate=300',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: '/api/risks',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/api/search/route.ts
Normal file
50
app/api/search/route.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { searchAircraft } from '@/lib/search/opensearch-client';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const rateLimitResult = rateLimit(getRateLimitIdentifier(request));
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Слишком много запросов' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { query, type = 'all', limit = 20, filters = {} } = body;
|
||||||
|
|
||||||
|
if (!query || typeof query !== 'string') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Параметр query обязателен' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск через OpenSearch
|
||||||
|
const results: any[] = [];
|
||||||
|
|
||||||
|
if (type === 'aircraft' || type === 'all') {
|
||||||
|
const aircraftResults = await searchAircraft(query, filters);
|
||||||
|
results.push(...aircraftResults.map(r => ({
|
||||||
|
type: 'aircraft',
|
||||||
|
...r,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Можно добавить поиск по другим типам (audits, risks, etc.)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
results: results.slice(0, limit),
|
||||||
|
total: results.length,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: '/api/search',
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/api/stats/route.ts
Normal file
39
app/api/stats/route.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCachedStats } from '@/lib/api/cached-api';
|
||||||
|
import { handleError } from '@/lib/error-handler';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Route для получения статистики
|
||||||
|
* Поддерживает кэширование (TTL: 5 минут)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Rate limiting (мягкий лимит)
|
||||||
|
try {
|
||||||
|
const identifier = getRateLimitIdentifier(request);
|
||||||
|
const rateLimitResult = rateLimit(identifier, 200, 60000);
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
console.warn('Rate limit warning for /api/stats');
|
||||||
|
}
|
||||||
|
} catch (rateLimitError) {
|
||||||
|
console.warn('Rate limit check failed, continuing:', rateLimitError);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await getCachedStats();
|
||||||
|
|
||||||
|
return NextResponse.json(stats, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleError(error, {
|
||||||
|
path: '/api/stats',
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/api/tracing/route.ts
Normal file
53
app/api/tracing/route.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { tracer } from '@/lib/tracing/tracer';
|
||||||
|
import { rateLimit, getRateLimitIdentifier } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const rateLimitResult = rateLimit(getRateLimitIdentifier(request), 100, 60000);
|
||||||
|
if (!rateLimitResult.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Слишком много запросов' },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const traceId = searchParams.get('traceId');
|
||||||
|
|
||||||
|
if (traceId) {
|
||||||
|
// Получить конкретный trace
|
||||||
|
const trace = tracer.exportTrace(traceId);
|
||||||
|
if (!trace) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Trace not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json(trace);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить все traces (последние 100)
|
||||||
|
const allTraces = tracer.getAllTraces();
|
||||||
|
const traces = Array.from(allTraces.entries())
|
||||||
|
.slice(-100)
|
||||||
|
.map(([traceId, spans]) => ({
|
||||||
|
traceId,
|
||||||
|
spanCount: spans.length,
|
||||||
|
duration: spans[spans.length - 1]?.duration || 0,
|
||||||
|
status: spans[spans.length - 1]?.status || 'unknown',
|
||||||
|
operation: spans[0]?.operation || 'unknown',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
traces,
|
||||||
|
total: allTraces.size,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
212
app/applications/page.tsx
Normal file
212
app/applications/page.tsx
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import ApplicationCardModal from '@/components/ApplicationCardModal';
|
||||||
|
import ApplicationCreateModal from '@/components/ApplicationCreateModal';
|
||||||
|
import Logo from '@/components/Logo';
|
||||||
|
|
||||||
|
interface Application {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
aircraft: string;
|
||||||
|
date: string;
|
||||||
|
organization: string;
|
||||||
|
description?: string;
|
||||||
|
comments?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ApplicationsPage() {
|
||||||
|
const [applications, setApplications] = useState<Application[]>([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
number: 'APP-2025-001',
|
||||||
|
type: 'Регистрация ВС',
|
||||||
|
status: 'В обработке',
|
||||||
|
aircraft: 'RA-12345',
|
||||||
|
date: '2025-01-15',
|
||||||
|
organization: 'Аэрофлот',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
number: 'APP-2025-002',
|
||||||
|
type: 'Сертификация',
|
||||||
|
status: 'На рассмотрении',
|
||||||
|
aircraft: 'RA-67890',
|
||||||
|
date: '2025-01-14',
|
||||||
|
organization: 'S7 Airlines',
|
||||||
|
description: 'Заявка на сертификацию воздушного судна RA-67890',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [selectedApplication, setSelectedApplication] = useState<Application | null>(null);
|
||||||
|
const [isCardModalOpen, setIsCardModalOpen] = useState(false);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleOpen = (application: Application) => {
|
||||||
|
setSelectedApplication(application);
|
||||||
|
setIsCardModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveApplication = (updatedApplication: Application) => {
|
||||||
|
setApplications(prev => prev.map(a => a.id === updatedApplication.id ? updatedApplication : a));
|
||||||
|
setSelectedApplication(updatedApplication);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateApplication = (applicationData: any) => {
|
||||||
|
const newApplication: Application = {
|
||||||
|
id: `app-${Date.now()}`,
|
||||||
|
...applicationData,
|
||||||
|
};
|
||||||
|
setApplications(prev => [...prev, newApplication]);
|
||||||
|
alert('Заявка успешно создана');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<div style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<Logo size="large" />
|
||||||
|
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||||
|
Система контроля лётной годности воздушных судов · Безопасность и качество
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
Заявки
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
Управление заявками на регистрацию и сертификацию воздушных судов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Создать заявку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px', display: 'flex', gap: '8px' }}>
|
||||||
|
<button style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
Все
|
||||||
|
</button>
|
||||||
|
<button style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: '#1e3a5f',
|
||||||
|
border: '1px solid #1e3a5f',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
В обработке
|
||||||
|
</button>
|
||||||
|
<button style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: '#1e3a5f',
|
||||||
|
border: '1px solid #1e3a5f',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
На рассмотрении
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
{applications.map(app => (
|
||||||
|
<div key={app.id} style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: '24px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
{app.number}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||||
|
Тип: {app.type}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||||
|
ВС: {app.aircraft} | Организация: {app.organization}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
Дата: {app.date}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<span style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
backgroundColor: app.status === 'В обработке' ? '#ff9800' : '#2196f3',
|
||||||
|
color: 'white',
|
||||||
|
}}>
|
||||||
|
{app.status}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpen(app)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Открыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ApplicationCardModal
|
||||||
|
isOpen={isCardModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsCardModalOpen(false);
|
||||||
|
setSelectedApplication(null);
|
||||||
|
}}
|
||||||
|
application={selectedApplication}
|
||||||
|
onSave={handleSaveApplication}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApplicationCreateModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
onCreate={handleCreateApplication}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
453
app/audit-history/page.tsx
Normal file
453
app/audit-history/page.tsx
Normal file
@ -0,0 +1,453 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import Logo from '@/components/Logo';
|
||||||
|
import Pagination from '@/components/Pagination';
|
||||||
|
|
||||||
|
interface AuditLog {
|
||||||
|
id: string;
|
||||||
|
userId?: string;
|
||||||
|
userName?: string;
|
||||||
|
action: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceId: string;
|
||||||
|
oldValues?: Record<string, any>;
|
||||||
|
newValues?: Record<string, any>;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditHistoryPage() {
|
||||||
|
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
action: '',
|
||||||
|
resourceType: '',
|
||||||
|
search: '',
|
||||||
|
startDate: '',
|
||||||
|
endDate: '',
|
||||||
|
});
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize] = useState(50);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLogs();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentPage, filters]);
|
||||||
|
|
||||||
|
const loadLogs = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: pageSize.toString(),
|
||||||
|
offset: ((currentPage - 1) * pageSize).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filters.action) {
|
||||||
|
params.append('action', filters.action);
|
||||||
|
}
|
||||||
|
if (filters.resourceType) {
|
||||||
|
params.append('resourceType', filters.resourceType);
|
||||||
|
}
|
||||||
|
if (filters.search) {
|
||||||
|
params.append('search', filters.search);
|
||||||
|
}
|
||||||
|
if (filters.startDate) {
|
||||||
|
params.append('startDate', filters.startDate);
|
||||||
|
}
|
||||||
|
if (filters.endDate) {
|
||||||
|
params.append('endDate', filters.endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/audit?${params.toString()}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
setLogs(data.logs || []);
|
||||||
|
setTotal(data.total || 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки истории аудита:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExport = async (format: 'json' | 'csv') => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
format,
|
||||||
|
limit: '10000',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filters.action) {
|
||||||
|
params.append('action', filters.action);
|
||||||
|
}
|
||||||
|
if (filters.resourceType) {
|
||||||
|
params.append('resourceType', filters.resourceType);
|
||||||
|
}
|
||||||
|
if (filters.search) {
|
||||||
|
params.append('search', filters.search);
|
||||||
|
}
|
||||||
|
if (filters.startDate) {
|
||||||
|
params.append('startDate', filters.startDate);
|
||||||
|
}
|
||||||
|
if (filters.endDate) {
|
||||||
|
params.append('endDate', filters.endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/audit?${params.toString()}`);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `audit_logs_${new Date().toISOString().split('T')[0]}.${format}`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка экспорта:', error);
|
||||||
|
alert('Ошибка экспорта логов');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRollback = async (logId: string) => {
|
||||||
|
if (!confirm('Вы уверены, что хотите откатить это изменение?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/audit/rollback', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ auditLogId: logId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
alert('Изменения успешно откачены');
|
||||||
|
loadLogs();
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Ошибка при откате изменений');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка отката:', error);
|
||||||
|
alert('Ошибка при откате изменений');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionColor = (action: string) => {
|
||||||
|
switch (action) {
|
||||||
|
case 'CREATE': return '#4caf50';
|
||||||
|
case 'UPDATE': return '#2196f3';
|
||||||
|
case 'DELETE': return '#f44336';
|
||||||
|
case 'VIEW': return '#9e9e9e';
|
||||||
|
case 'EXPORT': return '#ff9800';
|
||||||
|
case 'ROLLBACK': return '#9c27b0';
|
||||||
|
default: return '#666';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResourceTypeLabel = (type: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
aircraft: 'Воздушное судно',
|
||||||
|
risk: 'Риск',
|
||||||
|
organization: 'Организация',
|
||||||
|
user: 'Пользователь',
|
||||||
|
audit: 'Аудит',
|
||||||
|
checklist: 'Чек-лист',
|
||||||
|
application: 'Заявка',
|
||||||
|
document: 'Документ',
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<div id="main-content" role="main" style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<Logo size="large" />
|
||||||
|
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||||
|
Система контроля лётной годности воздушных судов · Безопасность и качество
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
История изменений
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
Аудит всех действий пользователей в системе
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport('csv')}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#4caf50',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Экспорт CSV
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExport('json')}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#2196f3',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Экспорт JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Фильтры */}
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: '24px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
marginBottom: '24px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
Действие
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.action}
|
||||||
|
onChange={(e) => setFilters({ ...filters, action: e.target.value })}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Все</option>
|
||||||
|
<option value="CREATE">Создание</option>
|
||||||
|
<option value="UPDATE">Изменение</option>
|
||||||
|
<option value="DELETE">Удаление</option>
|
||||||
|
<option value="VIEW">Просмотр</option>
|
||||||
|
<option value="EXPORT">Экспорт</option>
|
||||||
|
<option value="ROLLBACK">Откат</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
Тип ресурса
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.resourceType}
|
||||||
|
onChange={(e) => setFilters({ ...filters, resourceType: e.target.value })}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Все</option>
|
||||||
|
<option value="aircraft">Воздушное судно</option>
|
||||||
|
<option value="risk">Риск</option>
|
||||||
|
<option value="organization">Организация</option>
|
||||||
|
<option value="user">Пользователь</option>
|
||||||
|
<option value="audit">Аудит</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
Поиск
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Пользователь, действие, IP..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
Дата с
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.startDate}
|
||||||
|
onChange={(e) => setFilters({ ...filters, startDate: e.target.value })}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
Дата по
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.endDate}
|
||||||
|
onChange={(e) => setFilters({ ...filters, endDate: e.target.value })}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px',
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Таблица логов */}
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<div>Загрузка истории изменений...</div>
|
||||||
|
</div>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#e3f2fd',
|
||||||
|
padding: '40px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
Записи не найдены
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
Попробуйте изменить фильтры
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginBottom: '24px',
|
||||||
|
}}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ backgroundColor: '#f5f5f5' }}>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontSize: '14px', fontWeight: 'bold' }}>Дата</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontSize: '14px', fontWeight: 'bold' }}>Пользователь</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontSize: '14px', fontWeight: 'bold' }}>Действие</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontSize: '14px', fontWeight: 'bold' }}>Ресурс</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontSize: '14px', fontWeight: 'bold' }}>IP адрес</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontSize: '14px', fontWeight: 'bold' }}>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logs.map((log) => (
|
||||||
|
<tr key={log.id} style={{ borderTop: '1px solid #e0e0e0' }}>
|
||||||
|
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||||
|
{new Date(log.createdAt).toLocaleString('ru-RU')}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||||
|
{log.userName || 'Система'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||||
|
<span style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
backgroundColor: getActionColor(log.action),
|
||||||
|
color: 'white',
|
||||||
|
}}>
|
||||||
|
{log.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||||
|
{getResourceTypeLabel(log.resourceType)} ({log.resourceId.slice(0, 8)}...)
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||||
|
{log.ipAddress || '-'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px', fontSize: '14px' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// Показать детали изменения
|
||||||
|
const details = (log.oldValues || log.newValues)
|
||||||
|
? `Старые значения: ${JSON.stringify(log.oldValues || {}, null, 2)}\n\nНовые значения: ${JSON.stringify(log.newValues || {}, null, 2)}`
|
||||||
|
: 'Нет деталей';
|
||||||
|
alert(details);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
backgroundColor: '#2196f3',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Детали
|
||||||
|
</button>
|
||||||
|
{log.action === 'UPDATE' && log.oldValues && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRollback(log.id)}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
backgroundColor: '#9c27b0',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Откатить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
total={total}
|
||||||
|
limit={pageSize}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
app/audits/page.tsx
Normal file
213
app/audits/page.tsx
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import AuditCardModal from '@/components/AuditCardModal';
|
||||||
|
import AuditCreateModal from '@/components/AuditCreateModal';
|
||||||
|
import Logo from '@/components/Logo';
|
||||||
|
|
||||||
|
interface Audit {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
organization: string;
|
||||||
|
date: string;
|
||||||
|
inspector: string;
|
||||||
|
description?: string;
|
||||||
|
findings?: string;
|
||||||
|
recommendations?: string;
|
||||||
|
deadline?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditsPage() {
|
||||||
|
const [audits, setAudits] = useState<Audit[]>([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
number: 'AUD-2025-001',
|
||||||
|
type: 'Плановый аудит',
|
||||||
|
status: 'Запланирован',
|
||||||
|
organization: 'Аэрофлот',
|
||||||
|
date: '2025-02-01',
|
||||||
|
inspector: 'Иванов И.И.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
number: 'AUD-2025-002',
|
||||||
|
type: 'Внеплановый аудит',
|
||||||
|
status: 'В процессе',
|
||||||
|
organization: 'S7 Airlines',
|
||||||
|
date: '2025-01-21',
|
||||||
|
inspector: 'Петров П.П.',
|
||||||
|
description: 'Проведение внепланового аудита организации S7 Airlines для проверки соответствия требованиям безопасности.',
|
||||||
|
findings: 'Выявлены незначительные нарушения в ведении документации.',
|
||||||
|
recommendations: 'Рекомендуется обновить систему документооборота и провести обучение персонала.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [selectedAudit, setSelectedAudit] = useState<Audit | null>(null);
|
||||||
|
const [isCardModalOpen, setIsCardModalOpen] = useState(false);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleOpen = (audit: Audit) => {
|
||||||
|
setSelectedAudit(audit);
|
||||||
|
setIsCardModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAudit = (updatedAudit: Audit) => {
|
||||||
|
setAudits(prev => prev.map(a => a.id === updatedAudit.id ? updatedAudit : a));
|
||||||
|
setSelectedAudit(updatedAudit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateAudit = (auditData: any) => {
|
||||||
|
const newAudit: Audit = {
|
||||||
|
id: `audit-${Date.now()}`,
|
||||||
|
...auditData,
|
||||||
|
};
|
||||||
|
setAudits(prev => [...prev, newAudit]);
|
||||||
|
alert('Аудит успешно создан');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<div style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<Logo size="large" />
|
||||||
|
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||||
|
Система контроля лётной годности воздушных судов · Безопасность и качество
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
Аудиты
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
Управление аудитами и проверками воздушных судов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Создать аудит
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px', display: 'flex', gap: '8px' }}>
|
||||||
|
<button style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
Все
|
||||||
|
</button>
|
||||||
|
<button style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: '#1e3a5f',
|
||||||
|
border: '1px solid #1e3a5f',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
В процессе
|
||||||
|
</button>
|
||||||
|
<button style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: '#1e3a5f',
|
||||||
|
border: '1px solid #1e3a5f',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
Завершённые
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
{audits.map(audit => (
|
||||||
|
<div key={audit.id} style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: '24px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
{audit.number}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||||
|
Тип: {audit.type} | Организация: {audit.organization}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||||
|
Инспектор: {audit.inspector} | Дата: {audit.date}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<span style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
backgroundColor: audit.status === 'В процессе' ? '#ff9800' : '#2196f3',
|
||||||
|
color: 'white',
|
||||||
|
}}>
|
||||||
|
{audit.status}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpen(audit)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Открыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AuditCardModal
|
||||||
|
isOpen={isCardModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsCardModalOpen(false);
|
||||||
|
setSelectedAudit(null);
|
||||||
|
}}
|
||||||
|
audit={selectedAudit}
|
||||||
|
onSave={handleSaveAudit}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AuditCreateModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
onCreate={handleCreateAudit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
268
app/checklists/page.tsx
Normal file
268
app/checklists/page.tsx
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import ChecklistCardModal from '@/components/ChecklistCardModal';
|
||||||
|
import ChecklistCreateModal from '@/components/ChecklistCreateModal';
|
||||||
|
import Logo from '@/components/Logo';
|
||||||
|
|
||||||
|
interface Checklist {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
aircraft: string;
|
||||||
|
date: string;
|
||||||
|
items: number;
|
||||||
|
completed: number;
|
||||||
|
description?: string;
|
||||||
|
checklistItems?: Array<{ id: string; text: string; checked: boolean }>;
|
||||||
|
standards?: { icao?: boolean; easa?: boolean; faa?: boolean; armak?: boolean };
|
||||||
|
inspector?: string;
|
||||||
|
inspectorLicense?: string;
|
||||||
|
operator?: string;
|
||||||
|
checklistNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChecklistsPage() {
|
||||||
|
const [checklists, setChecklists] = useState<Checklist[]>([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Предполётный осмотр',
|
||||||
|
type: 'Ежедневный',
|
||||||
|
status: 'В процессе',
|
||||||
|
aircraft: 'RA-12345',
|
||||||
|
date: '2025-01-21',
|
||||||
|
items: 25,
|
||||||
|
completed: 18,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Техническое обслуживание',
|
||||||
|
type: 'Периодический',
|
||||||
|
status: 'Завершён',
|
||||||
|
aircraft: 'RA-67890',
|
||||||
|
date: '2025-01-20',
|
||||||
|
items: 45,
|
||||||
|
completed: 45,
|
||||||
|
description: 'Периодическое техническое обслуживание воздушного судна RA-67890',
|
||||||
|
checklistItems: [
|
||||||
|
{ id: '1', text: 'Проверка двигателя', checked: true },
|
||||||
|
{ id: '2', text: 'Проверка шасси', checked: true },
|
||||||
|
{ id: '3', text: 'Проверка системы навигации', checked: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [selectedChecklist, setSelectedChecklist] = useState<Checklist | null>(null);
|
||||||
|
const [isCardModalOpen, setIsCardModalOpen] = useState(false);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleOpen = (checklist: Checklist) => {
|
||||||
|
setSelectedChecklist(checklist);
|
||||||
|
setIsCardModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveChecklist = (updatedChecklist: Checklist) => {
|
||||||
|
setChecklists(prev => prev.map(c => c.id === updatedChecklist.id ? updatedChecklist : c));
|
||||||
|
setSelectedChecklist(updatedChecklist);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateChecklist = (checklistData: any) => {
|
||||||
|
const newChecklist: Checklist = {
|
||||||
|
id: `checklist-${Date.now()}`,
|
||||||
|
...checklistData,
|
||||||
|
};
|
||||||
|
setChecklists(prev => [...prev, newChecklist]);
|
||||||
|
alert('Чек-лист успешно создан');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<div style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<Logo size="large" />
|
||||||
|
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||||
|
Система контроля лётной годности воздушных судов · Безопасность и качество
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
Чек-листы
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
Управление чек-листами для проверки воздушных судов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Создать чек-лист
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
{checklists.map(checklist => (
|
||||||
|
<div key={checklist.id} style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: '24px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '16px' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
{checklist.name}
|
||||||
|
</div>
|
||||||
|
{checklist.checklistNumber && (
|
||||||
|
<div style={{ fontSize: '12px', color: '#999', marginBottom: '4px' }}>
|
||||||
|
№ {checklist.checklistNumber}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Отображение стандартов соответствия */}
|
||||||
|
{checklist.standards && (
|
||||||
|
<div style={{ display: 'flex', gap: '6px', marginBottom: '8px', flexWrap: 'wrap' }}>
|
||||||
|
{checklist.standards.icao && (
|
||||||
|
<span style={{
|
||||||
|
padding: '3px 8px',
|
||||||
|
backgroundColor: '#e3f2fd',
|
||||||
|
color: '#1976d2',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: '500',
|
||||||
|
}}>
|
||||||
|
ICAO
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{checklist.standards.easa && (
|
||||||
|
<span style={{
|
||||||
|
padding: '3px 8px',
|
||||||
|
backgroundColor: '#fff3e0',
|
||||||
|
color: '#e65100',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: '500',
|
||||||
|
}}>
|
||||||
|
EASA
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{checklist.standards.faa && (
|
||||||
|
<span style={{
|
||||||
|
padding: '3px 8px',
|
||||||
|
backgroundColor: '#f3e5f5',
|
||||||
|
color: '#7b1fa2',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: '500',
|
||||||
|
}}>
|
||||||
|
FAA
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{checklist.standards.armak && (
|
||||||
|
<span style={{
|
||||||
|
padding: '3px 8px',
|
||||||
|
backgroundColor: '#e8f5e9',
|
||||||
|
color: '#2e7d32',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '10px',
|
||||||
|
fontWeight: '500',
|
||||||
|
}}>
|
||||||
|
АРМАК
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||||
|
Тип: {checklist.type} | ВС: {checklist.aircraft}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
Дата: {checklist.date}
|
||||||
|
{checklist.operator && ` | Оператор: ${checklist.operator}`}
|
||||||
|
{checklist.inspector && ` | Инспектор: ${checklist.inspector}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<span style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
backgroundColor: checklist.status === 'Завершён' ? '#4caf50' : '#ff9800',
|
||||||
|
color: 'white',
|
||||||
|
}}>
|
||||||
|
{checklist.status}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpen(checklist)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Открыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||||
|
<span style={{ fontSize: '14px' }}>Прогресс выполнения</span>
|
||||||
|
<span style={{ fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
{checklist.completed} / {checklist.items}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '24px',
|
||||||
|
backgroundColor: '#e0e0e0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: `${(checklist.completed / checklist.items) * 100}%`,
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: checklist.status === 'Завершён' ? '#4caf50' : '#1e3a5f',
|
||||||
|
transition: 'width 0.3s ease',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChecklistCardModal
|
||||||
|
isOpen={isCardModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsCardModalOpen(false);
|
||||||
|
setSelectedChecklist(null);
|
||||||
|
}}
|
||||||
|
checklist={selectedChecklist}
|
||||||
|
onSave={handleSaveChecklist}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChecklistCreateModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
onCreate={handleCreateChecklist}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
785
app/dashboard/page.tsx
Normal file
785
app/dashboard/page.tsx
Normal file
@ -0,0 +1,785 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import FileUploadModal from '@/components/FileUploadModal';
|
||||||
|
import AIAgentModal from '@/components/AIAgentModal';
|
||||||
|
import SearchModal from '@/components/SearchModal';
|
||||||
|
import ExportModal from '@/components/ExportModal';
|
||||||
|
import Logo from '@/components/Logo';
|
||||||
|
import NotificationBell from '@/components/NotificationBell';
|
||||||
|
import SettingsModal from '@/components/SettingsModal';
|
||||||
|
import SemanticSearch from '@/components/SemanticSearch';
|
||||||
|
import AutonomousAgentInterface from '@/components/AutonomousAgentInterface';
|
||||||
|
import KnowledgeGraphVisualization from '@/components/KnowledgeGraphVisualization';
|
||||||
|
import { Aircraft } from '@/lib/api';
|
||||||
|
import { useGlobalShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||||
|
import { useAircraftData, useStatsData, useRisksData, useAuditsData } from '@/hooks/useSWRData';
|
||||||
|
import { logInfo } from '@/lib/logger-client';
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
|
||||||
|
const [isAIAgentModalOpen, setIsAIAgentModalOpen] = useState(false);
|
||||||
|
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
||||||
|
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||||
|
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// ARC-004: только SWR, без прямого fetch (избегаем двойной нагрузки на API)
|
||||||
|
const { data: aircraftData, isLoading: aircraftLoading, error: aircraftError, mutate: mutateAircraft } = useAircraftData();
|
||||||
|
const { data: statsData, error: statsError } = useStatsData();
|
||||||
|
const { data: risksData } = useRisksData();
|
||||||
|
const { data: auditsData } = useAuditsData();
|
||||||
|
|
||||||
|
const aircraft = useMemo(() => {
|
||||||
|
if (Array.isArray(aircraftData)) return aircraftData;
|
||||||
|
if (aircraftData?.data) return aircraftData.data;
|
||||||
|
return [];
|
||||||
|
}, [aircraftData]);
|
||||||
|
|
||||||
|
const directRisks = Array.isArray(risksData) ? risksData : (Array.isArray(risksData?.data) ? risksData.data : []);
|
||||||
|
const directAudits = Array.isArray(auditsData) ? auditsData : (Array.isArray(auditsData?.data) ? auditsData.data : []);
|
||||||
|
|
||||||
|
const [loadingTimeout, setLoadingTimeout] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (aircraftLoading) {
|
||||||
|
const timeout = setTimeout(() => setLoadingTimeout(true), 5000);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
setLoadingTimeout(false);
|
||||||
|
return undefined;
|
||||||
|
}, [aircraftLoading]);
|
||||||
|
|
||||||
|
const hasAnyData = aircraft.length > 0;
|
||||||
|
const isLoading = !hasAnyData && aircraftLoading && !aircraftError && !loadingTimeout;
|
||||||
|
|
||||||
|
const stats = statsData || {
|
||||||
|
aircraft: { total: 0, active: 0, maintenance: 0 },
|
||||||
|
risks: { total: 0, critical: 0, high: 0 },
|
||||||
|
audits: { current: 0, upcoming: 0, completed: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Глобальные горячие клавиши
|
||||||
|
useGlobalShortcuts({
|
||||||
|
onSearch: () => setIsSearchModalOpen(true),
|
||||||
|
onCreateNew: () => router.push('/aircraft'),
|
||||||
|
onClose: () => {
|
||||||
|
setIsUploadModalOpen(false);
|
||||||
|
setIsAIAgentModalOpen(false);
|
||||||
|
setIsSearchModalOpen(false);
|
||||||
|
setIsExportModalOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Вычисление статистики из данных
|
||||||
|
const [computedStats, setComputedStats] = useState({
|
||||||
|
total: 0,
|
||||||
|
active: 0,
|
||||||
|
maintenance: 0,
|
||||||
|
types: new Map<string, number>(),
|
||||||
|
operators: new Map<string, number>(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [risksStats, setRisksStats] = useState({
|
||||||
|
total: 0,
|
||||||
|
critical: 0,
|
||||||
|
high: 0,
|
||||||
|
medium: 0,
|
||||||
|
low: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [auditsStats, setAuditsStats] = useState({
|
||||||
|
current: 0,
|
||||||
|
upcoming: 0,
|
||||||
|
completed: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [operatorRatings, setOperatorRatings] = useState<Array<{
|
||||||
|
operator: string;
|
||||||
|
totalAircraft: number;
|
||||||
|
activeAircraft: number;
|
||||||
|
maintenanceAircraft: number;
|
||||||
|
rating: number;
|
||||||
|
category: 'best' | 'average' | 'worst';
|
||||||
|
}>>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (aircraft.length > 0) {
|
||||||
|
const newStats = {
|
||||||
|
total: aircraft.length,
|
||||||
|
active: 0,
|
||||||
|
maintenance: 0,
|
||||||
|
types: new Map<string, number>(),
|
||||||
|
operators: new Map<string, number>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
aircraft.forEach((a: Aircraft) => {
|
||||||
|
if (a.status?.toLowerCase().includes('активен')) {
|
||||||
|
newStats.active++;
|
||||||
|
}
|
||||||
|
if (a.status?.toLowerCase().includes('обслуживан') || a.status?.toLowerCase().includes('ремонт')) {
|
||||||
|
newStats.maintenance++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.aircraftType) {
|
||||||
|
newStats.types.set(a.aircraftType, (newStats.types.get(a.aircraftType) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.operator) {
|
||||||
|
newStats.operators.set(a.operator, (newStats.operators.get(a.operator) || 0) + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setComputedStats(newStats);
|
||||||
|
}
|
||||||
|
}, [aircraft]);
|
||||||
|
|
||||||
|
// Обновляем статистику рисков: приоритет прямым данным
|
||||||
|
useEffect(() => {
|
||||||
|
if (directRisks.length > 0) {
|
||||||
|
// Используем прямые данные (приоритет)
|
||||||
|
const calculatedStats = {
|
||||||
|
total: directRisks.length,
|
||||||
|
critical: directRisks.filter((r: any) => r.level === 'Критический').length,
|
||||||
|
high: directRisks.filter((r: any) => r.level === 'Высокий').length,
|
||||||
|
medium: directRisks.filter((r: any) => r.level === 'Средний').length,
|
||||||
|
low: directRisks.filter((r: any) => r.level === 'Низкий').length,
|
||||||
|
};
|
||||||
|
setRisksStats(calculatedStats);
|
||||||
|
} else if (stats.risks && (stats.risks.total > 0 || stats.risks.critical > 0 || stats.risks.high > 0)) {
|
||||||
|
// Используем данные из stats, если прямые данные недоступны
|
||||||
|
setRisksStats({
|
||||||
|
total: stats.risks.total || 0,
|
||||||
|
critical: stats.risks.critical || 0,
|
||||||
|
high: stats.risks.high || 0,
|
||||||
|
medium: 0,
|
||||||
|
low: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [stats.risks, directRisks]);
|
||||||
|
|
||||||
|
// Обновляем статистику аудитов: приоритет прямым данным
|
||||||
|
useEffect(() => {
|
||||||
|
if (directAudits.length > 0) {
|
||||||
|
// Используем прямые данные (приоритет)
|
||||||
|
const now = new Date();
|
||||||
|
const calculatedStats = {
|
||||||
|
current: directAudits.filter((a: any) => a.status === 'В процессе').length,
|
||||||
|
upcoming: directAudits.filter((a: any) => {
|
||||||
|
if (a.status !== 'Запланирован' || !a.date) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auditDate = new Date(a.date);
|
||||||
|
return auditDate >= now;
|
||||||
|
}).length,
|
||||||
|
completed: directAudits.filter((a: any) => a.status === 'Завершён').length,
|
||||||
|
};
|
||||||
|
setAuditsStats(calculatedStats);
|
||||||
|
} else if (stats.audits && (stats.audits.current > 0 || stats.audits.upcoming > 0 || stats.audits.completed > 0)) {
|
||||||
|
// Используем данные из stats, если прямые данные недоступны
|
||||||
|
setAuditsStats({
|
||||||
|
current: stats.audits.current || 0,
|
||||||
|
upcoming: stats.audits.upcoming || 0,
|
||||||
|
completed: stats.audits.completed || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [stats.audits, directAudits]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (aircraft.length > 0) {
|
||||||
|
const operatorData = new Map<string, { total: number; active: number; maintenance: number }>();
|
||||||
|
|
||||||
|
aircraft.forEach((a: Aircraft) => {
|
||||||
|
if (!a.operator) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!operatorData.has(a.operator)) {
|
||||||
|
operatorData.set(a.operator, { total: 0, active: 0, maintenance: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = operatorData.get(a.operator)!;
|
||||||
|
data.total++;
|
||||||
|
|
||||||
|
if (a.status?.toLowerCase().includes('активен')) {
|
||||||
|
data.active++;
|
||||||
|
}
|
||||||
|
if (a.status?.toLowerCase().includes('обслуживан') || a.status?.toLowerCase().includes('ремонт')) {
|
||||||
|
data.maintenance++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ratings = Array.from(operatorData.entries()).map(([operator, data]) => {
|
||||||
|
const activePercent = data.total > 0 ? (data.active / data.total) * 100 : 0;
|
||||||
|
const maintenancePercent = data.total > 0 ? (data.maintenance / data.total) * 100 : 0;
|
||||||
|
|
||||||
|
const rating = Math.round(
|
||||||
|
activePercent * 0.5 +
|
||||||
|
(100 - maintenancePercent) * 0.3 +
|
||||||
|
Math.min(data.total / 10, 1) * 100 * 0.2
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
operator,
|
||||||
|
totalAircraft: data.total,
|
||||||
|
activeAircraft: data.active,
|
||||||
|
maintenanceAircraft: data.maintenance,
|
||||||
|
rating,
|
||||||
|
category: rating >= 80 ? 'best' as const : rating >= 50 ? 'average' as const : 'worst' as const,
|
||||||
|
};
|
||||||
|
}).sort((a, b) => b.rating - a.rating);
|
||||||
|
|
||||||
|
setOperatorRatings(ratings);
|
||||||
|
}
|
||||||
|
}, [aircraft]);
|
||||||
|
|
||||||
|
const handleFileUpload = async (files: File[]) => {
|
||||||
|
logInfo('Загружено файлов', { count: files.length });
|
||||||
|
// Здесь будет логика загрузки файлов
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavigate = (path: string) => {
|
||||||
|
router.push(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<div id="main-content" role="main" style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<Logo size="large" />
|
||||||
|
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||||
|
Система контроля лётной годности воздушных судов · Безопасность и качество
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
Дашборд
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
Общая статистика и аналитика системы контроля лётной годности
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||||||
|
<NotificationBell />
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSettingsModalOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#666',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
title="Настройки"
|
||||||
|
aria-label="Открыть настройки"
|
||||||
|
>
|
||||||
|
<span>⚙️</span>
|
||||||
|
<span>Настройки</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAIAgentModalOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>🤖</span>
|
||||||
|
<span>ИИ Агент</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSearchModalOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#2196f3',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
title="Ctrl+K"
|
||||||
|
>
|
||||||
|
Поиск
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExportModalOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#4caf50',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>📥</span>
|
||||||
|
<span>Экспорт</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{aircraftError ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<div style={{ fontSize: '16px', color: '#f44336' }}>
|
||||||
|
Ошибка загрузки данных: {aircraftError.message || 'Неизвестная ошибка'}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#2196f3',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Перезагрузить страницу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : isLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<div style={{ fontSize: '16px', color: '#666' }}>Загрузка данных...</div>
|
||||||
|
<div style={{ marginTop: '16px', fontSize: '14px', color: '#999' }}>
|
||||||
|
Пожалуйста, подождите...
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => mutateAircraft()}
|
||||||
|
style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#2196f3',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Повторить загрузку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : aircraft.length === 0 && !aircraftLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<div style={{ fontSize: '16px', color: '#666' }}>Нет данных для отображения</div>
|
||||||
|
<button
|
||||||
|
onClick={() => mutateAircraft()}
|
||||||
|
style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#2196f3',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Загрузить данные
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : aircraft.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* Статистика воздушных судов */}
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<h3 style={{ fontSize: '20px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||||
|
Статистика воздушных судов
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||||
|
<div style={{
|
||||||
|
padding: '24px',
|
||||||
|
backgroundColor: '#f0f7ff',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 4px 12px rgba(30, 58, 95, 0.15)',
|
||||||
|
border: '2px solid #1e3a5f',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 6px 16px rgba(30, 58, 95, 0.25)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(30, 58, 95, 0.15)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1e3a5f', marginBottom: '8px', fontWeight: '500' }}>Всего ВС</div>
|
||||||
|
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#1e3a5f' }}>
|
||||||
|
{computedStats.total.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
padding: '24px',
|
||||||
|
backgroundColor: '#e8f5e9',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 4px 12px rgba(76, 175, 80, 0.15)',
|
||||||
|
border: '2px solid #4caf50',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 6px 16px rgba(76, 175, 80, 0.25)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(76, 175, 80, 0.15)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '14px', color: '#2e7d32', marginBottom: '8px', fontWeight: '500' }}>Активных</div>
|
||||||
|
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#4caf50' }}>
|
||||||
|
{computedStats.active.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
padding: '24px',
|
||||||
|
backgroundColor: '#fff3e0',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 4px 12px rgba(255, 152, 0, 0.15)',
|
||||||
|
border: '2px solid #ff9800',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 6px 16px rgba(255, 152, 0, 0.25)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(255, 152, 0, 0.15)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '14px', color: '#e65100', marginBottom: '8px', fontWeight: '500' }}>На обслуживании</div>
|
||||||
|
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#ff9800' }}>
|
||||||
|
{computedStats.maintenance.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Статистика рисков */}
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<h3 style={{ fontSize: '20px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||||
|
Статистика рисков
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '16px' }}>
|
||||||
|
<div style={{
|
||||||
|
padding: '24px',
|
||||||
|
backgroundColor: '#f0f7ff',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 4px 12px rgba(30, 58, 95, 0.15)',
|
||||||
|
border: '2px solid #1e3a5f',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 6px 16px rgba(30, 58, 95, 0.25)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(30, 58, 95, 0.15)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1e3a5f', marginBottom: '8px', fontWeight: '500' }}>Всего рисков</div>
|
||||||
|
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#1e3a5f' }}>
|
||||||
|
{risksStats.total}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
padding: '24px',
|
||||||
|
backgroundColor: '#ffebee',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 4px 12px rgba(244, 67, 54, 0.15)',
|
||||||
|
border: '2px solid #f44336',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 6px 16px rgba(244, 67, 54, 0.25)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(244, 67, 54, 0.15)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '14px', color: '#c62828', marginBottom: '8px', fontWeight: '500' }}>Критических</div>
|
||||||
|
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#f44336' }}>
|
||||||
|
{risksStats.critical}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
padding: '24px',
|
||||||
|
backgroundColor: '#fff3e0',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 4px 12px rgba(255, 152, 0, 0.15)',
|
||||||
|
border: '2px solid #ff9800',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 6px 16px rgba(255, 152, 0, 0.25)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(255, 152, 0, 0.15)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '14px', color: '#e65100', marginBottom: '8px', fontWeight: '500' }}>Высоких</div>
|
||||||
|
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#ff9800' }}>
|
||||||
|
{risksStats.high}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Статистика аудитов */}
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<h3 style={{ fontSize: '20px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||||
|
Статистика аудитов
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: '16px' }}>
|
||||||
|
<div style={{
|
||||||
|
padding: '24px',
|
||||||
|
backgroundColor: '#e3f2fd',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 4px 12px rgba(33, 150, 243, 0.15)',
|
||||||
|
border: '2px solid #2196f3',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 6px 16px rgba(33, 150, 243, 0.25)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(33, 150, 243, 0.15)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '14px', color: '#1565c0', marginBottom: '8px', fontWeight: '500' }}>Текущих</div>
|
||||||
|
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#2196f3' }}>
|
||||||
|
{auditsStats.current}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
padding: '24px',
|
||||||
|
backgroundColor: '#fff3e0',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 4px 12px rgba(255, 152, 0, 0.15)',
|
||||||
|
border: '2px solid #ff9800',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 6px 16px rgba(255, 152, 0, 0.25)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(255, 152, 0, 0.15)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '14px', color: '#e65100', marginBottom: '8px', fontWeight: '500' }}>Предстоящих</div>
|
||||||
|
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#ff9800' }}>
|
||||||
|
{auditsStats.upcoming}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
padding: '24px',
|
||||||
|
backgroundColor: '#e8f5e9',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 4px 12px rgba(76, 175, 80, 0.15)',
|
||||||
|
border: '2px solid #4caf50',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(-4px)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 6px 16px rgba(76, 175, 80, 0.25)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'translateY(0)';
|
||||||
|
e.currentTarget.style.boxShadow = '0 4px 12px rgba(76, 175, 80, 0.15)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '14px', color: '#2e7d32', marginBottom: '8px', fontWeight: '500' }}>Завершённых</div>
|
||||||
|
<div style={{ fontSize: '36px', fontWeight: 'bold', color: '#4caf50' }}>
|
||||||
|
{auditsStats.completed}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Рейтинг операторов по КЛГ */}
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<h3 style={{ fontSize: '20px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||||
|
Рейтинг операторов по КЛГ
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px' }}>
|
||||||
|
{/* Лучшие по КЛГ */}
|
||||||
|
<div style={{ padding: '20px', backgroundColor: 'white', borderRadius: '8px', boxShadow: '0 2px 4px rgba(0,0,0,0.1)' }}>
|
||||||
|
<h4 style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '12px', color: '#4caf50' }}>
|
||||||
|
Лучшие по КЛГ
|
||||||
|
</h4>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{operatorRatings
|
||||||
|
.filter(r => r.category === 'best')
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((rating, index) => (
|
||||||
|
<div key={index} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '14px' }}>
|
||||||
|
<span>{rating.operator}</span>
|
||||||
|
<span style={{ fontWeight: 'bold', color: '#4caf50' }}>{rating.rating}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{operatorRatings.filter(r => r.category === 'best').length === 0 && (
|
||||||
|
<div style={{ fontSize: '14px', color: '#999' }}>Нет данных</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Средние */}
|
||||||
|
<div style={{ padding: '20px', backgroundColor: 'white', borderRadius: '8px', boxShadow: '0 2px 4px rgba(0,0,0,0.1)' }}>
|
||||||
|
<h4 style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '12px', color: '#ff9800' }}>
|
||||||
|
Средние
|
||||||
|
</h4>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{operatorRatings
|
||||||
|
.filter(r => r.category === 'average')
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((rating, index) => (
|
||||||
|
<div key={index} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '14px' }}>
|
||||||
|
<span>{rating.operator}</span>
|
||||||
|
<span style={{ fontWeight: 'bold', color: '#ff9800' }}>{rating.rating}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{operatorRatings.filter(r => r.category === 'average').length === 0 && (
|
||||||
|
<div style={{ fontSize: '14px', color: '#999' }}>Нет данных</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Требуют внимания */}
|
||||||
|
<div style={{ padding: '20px', backgroundColor: 'white', borderRadius: '8px', boxShadow: '0 2px 4px rgba(0,0,0,0.1)' }}>
|
||||||
|
<h4 style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '12px', color: '#f44336' }}>
|
||||||
|
Требуют внимания
|
||||||
|
</h4>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{operatorRatings
|
||||||
|
.filter(r => r.category === 'worst')
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((rating, index) => (
|
||||||
|
<div key={index} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '14px' }}>
|
||||||
|
<span>{rating.operator}</span>
|
||||||
|
<span style={{ fontWeight: 'bold', color: '#f44336' }}>{rating.rating}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{operatorRatings.filter(r => r.category === 'worst').length === 0 && (
|
||||||
|
<div style={{ fontSize: '14px', color: '#999' }}>Нет данных</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* AI-Powered Knowledge System - показываем только если есть данные или не идет загрузка */}
|
||||||
|
{(!isLoading && hasAnyData) && (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<h3 style={{ fontSize: '20px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||||
|
Система знаний на основе ИИ
|
||||||
|
</h3>
|
||||||
|
<SemanticSearch
|
||||||
|
onResultSelect={(result) => {
|
||||||
|
logInfo('Selected result', { result });
|
||||||
|
// Можно добавить навигацию к результату
|
||||||
|
}}
|
||||||
|
placeholder="Семантический поиск по базе знаний..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<AutonomousAgentInterface />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<h3 style={{ fontSize: '20px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||||
|
Визуализация графа знаний
|
||||||
|
</h3>
|
||||||
|
<KnowledgeGraphVisualization
|
||||||
|
onNodeClick={(nodeId) => {
|
||||||
|
logInfo('Knowledge graph node clicked', { nodeId });
|
||||||
|
// Можно добавить навигацию к узлу
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Модальные окна */}
|
||||||
|
<AIAgentModal
|
||||||
|
isOpen={isAIAgentModalOpen}
|
||||||
|
onClose={() => setIsAIAgentModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<FileUploadModal
|
||||||
|
isOpen={isUploadModalOpen}
|
||||||
|
onClose={() => setIsUploadModalOpen(false)}
|
||||||
|
onUpload={handleFileUpload}
|
||||||
|
/>
|
||||||
|
<SearchModal
|
||||||
|
isOpen={isSearchModalOpen}
|
||||||
|
onClose={() => setIsSearchModalOpen(false)}
|
||||||
|
aircraft={aircraft}
|
||||||
|
searchType="dashboard"
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
/>
|
||||||
|
<ExportModal
|
||||||
|
isOpen={isExportModalOpen}
|
||||||
|
onClose={() => setIsExportModalOpen(false)}
|
||||||
|
data={aircraft}
|
||||||
|
filename="aircraft-export"
|
||||||
|
title="Экспорт воздушных судов"
|
||||||
|
availableColumns={aircraft.length > 0 ? Object.keys(aircraft[0]) : []}
|
||||||
|
columnLabels={{
|
||||||
|
registrationNumber: 'Регистрационный номер',
|
||||||
|
aircraftType: 'Тип ВС',
|
||||||
|
operator: 'Оператор',
|
||||||
|
status: 'Статус',
|
||||||
|
manufacturer: 'Производитель',
|
||||||
|
model: 'Модель',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SettingsModal
|
||||||
|
isOpen={isSettingsModalOpen}
|
||||||
|
onClose={() => setIsSettingsModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
app/documents/page.tsx
Normal file
226
app/documents/page.tsx
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import DocumentViewModal from '@/components/DocumentViewModal';
|
||||||
|
import Logo from '@/components/Logo';
|
||||||
|
|
||||||
|
interface Document {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
aircraft: string;
|
||||||
|
date: string;
|
||||||
|
status: string;
|
||||||
|
size: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentsPage() {
|
||||||
|
const [documents, setDocuments] = useState<Document[]>([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Сертификат лётной годности',
|
||||||
|
type: 'Сертификат',
|
||||||
|
aircraft: 'RA-12345',
|
||||||
|
date: '2025-01-15',
|
||||||
|
status: 'Действителен',
|
||||||
|
size: '2.5 МБ',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Техническая документация',
|
||||||
|
type: 'Техническая',
|
||||||
|
aircraft: 'RA-67890',
|
||||||
|
date: '2025-01-10',
|
||||||
|
status: 'Действителен',
|
||||||
|
size: '15.3 МБ',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Отчёт о техническом обслуживании',
|
||||||
|
type: 'Отчёт',
|
||||||
|
aircraft: 'RA-11111',
|
||||||
|
date: '2025-01-20',
|
||||||
|
status: 'Требует обновления',
|
||||||
|
size: '1.2 МБ',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [selectedDocument, setSelectedDocument] = useState<Document | null>(null);
|
||||||
|
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleDownload = (doc: Document) => {
|
||||||
|
// Имитация скачивания документа
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = '#';
|
||||||
|
link.download = `${doc.name}.pdf`;
|
||||||
|
link.click();
|
||||||
|
alert(`Документ "${doc.name}" скачивается...`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleView = (doc: Document) => {
|
||||||
|
setSelectedDocument(doc);
|
||||||
|
setIsViewModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = (doc: Document) => {
|
||||||
|
const newStatus = doc.status === 'Действителен' ? 'Требует обновления' : 'Действителен';
|
||||||
|
setDocuments(prev => prev.map(d =>
|
||||||
|
d.id === doc.id ? { ...d, status: newStatus } : d
|
||||||
|
));
|
||||||
|
alert(`Статус документа "${doc.name}" изменён на "${newStatus}"`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<div style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<Logo size="large" />
|
||||||
|
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||||
|
Система контроля лётной годности воздушных судов · Безопасность и качество
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
Документы
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
Управление документацией воздушных судов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
Загрузить документ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px', display: 'flex', gap: '8px' }}>
|
||||||
|
<button style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
Все
|
||||||
|
</button>
|
||||||
|
<button style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: '#1e3a5f',
|
||||||
|
border: '1px solid #1e3a5f',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
Сертификаты
|
||||||
|
</button>
|
||||||
|
<button style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: '#1e3a5f',
|
||||||
|
border: '1px solid #1e3a5f',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
Отчёты
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
{documents.map(doc => (
|
||||||
|
<div key={doc.id} style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: '24px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
{doc.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||||
|
Тип: {doc.type} | ВС: {doc.aircraft} | Размер: {doc.size}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
Дата: {doc.date}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleStatusChange(doc)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
backgroundColor: doc.status === 'Действителен' ? '#4caf50' : '#ff9800',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
title="Нажмите для изменения статуса"
|
||||||
|
>
|
||||||
|
{doc.status}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload(doc)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Скачать
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleView(doc)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: '#1e3a5f',
|
||||||
|
border: '1px solid #1e3a5f',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Просмотр
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocumentViewModal
|
||||||
|
isOpen={isViewModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsViewModalOpen(false);
|
||||||
|
setSelectedDocument(null);
|
||||||
|
}}
|
||||||
|
document={selectedDocument}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
app/error.tsx
Normal file
60
app/error.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Глобальная страница ошибок Next.js
|
||||||
|
*/
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import ErrorDisplay from '@/components/ErrorDisplay';
|
||||||
|
import { getUserFriendlyError } from '@/lib/errors/user-friendly-messages';
|
||||||
|
import { captureException } from '@/lib/monitoring/sentry';
|
||||||
|
|
||||||
|
export default function Error({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
// Логирование ошибки
|
||||||
|
console.error('Global error:', error);
|
||||||
|
|
||||||
|
// Отправка в Sentry
|
||||||
|
if (process.env.NEXT_PUBLIC_SENTRY_DSN) {
|
||||||
|
try {
|
||||||
|
captureException(error, {
|
||||||
|
digest: error.digest,
|
||||||
|
component: 'global-error',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Игнорируем ошибки Sentry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
const friendlyError = getUserFriendlyError(error);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
padding: '40px',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ maxWidth: '600px', width: '100%' }}>
|
||||||
|
<ErrorDisplay
|
||||||
|
title={friendlyError.title}
|
||||||
|
message={friendlyError.message}
|
||||||
|
type={friendlyError.type}
|
||||||
|
onRetry={reset}
|
||||||
|
showDetails={process.env.NODE_ENV === 'development'}
|
||||||
|
details={error.stack}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
app/global-error.tsx
Normal file
77
app/global-error.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Глобальный обработчик ошибок для корневого layout
|
||||||
|
*/
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
export default function GlobalError({
|
||||||
|
error: _error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<body>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
padding: '40px',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: '600px',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: '32px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: '16px' }}>⚠️</div>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: '12px',
|
||||||
|
color: '#333',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Критическая ошибка
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: '24px',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Произошла критическая ошибка приложения. Пожалуйста, обновите страницу.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={reset}
|
||||||
|
style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Обновить страницу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
app/globals.css
Normal file
135
app/globals.css
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
/* Глобальные стили с поддержкой доступности */
|
||||||
|
|
||||||
|
/* Базовые стили */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Фокус для доступности */
|
||||||
|
*:focus-visible {
|
||||||
|
outline: 2px solid #4a90e2;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible,
|
||||||
|
a:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: 2px solid #4a90e2;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Скрытие элементов для screen readers */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшение контраста для ссылок */
|
||||||
|
a {
|
||||||
|
color: #1e3a5f;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 2px solid #4a90e2;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для disabled элементов */
|
||||||
|
button:disabled,
|
||||||
|
input:disabled,
|
||||||
|
select:disabled,
|
||||||
|
textarea:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшение читаемости */
|
||||||
|
p, li {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Минимальный размер области клика (44x44px для touch) */
|
||||||
|
button,
|
||||||
|
a[role="button"] {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skip to main content link (для screen readers) */
|
||||||
|
.skip-to-main {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
left: 0;
|
||||||
|
background: #1e3a5f;
|
||||||
|
color: white;
|
||||||
|
padding: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-to-main:focus {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшение контраста для текста */
|
||||||
|
.text-primary {
|
||||||
|
color: #1e3a5f; /* Контраст с белым фоном: ~8.5:1 - AAA */
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-secondary {
|
||||||
|
color: #666; /* Контраст с белым фоном: ~7.1:1 - AAA */
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-error {
|
||||||
|
color: #c62828; /* Контраст с белым фоном: ~7.5:1 - AAA */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Анимации с учетом prefers-reduced-motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Высокий контраст режим */
|
||||||
|
@media (prefers-contrast: high) {
|
||||||
|
* {
|
||||||
|
border-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
a {
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
app/inbox/page.tsx
Normal file
77
app/inbox/page.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import Logo from '@/components/Logo';
|
||||||
|
|
||||||
|
const INBOX_API = '/api/inbox';
|
||||||
|
|
||||||
|
export default function InboxPage() {
|
||||||
|
const [files, setFiles] = useState<Array<{ id: string; original_name: string; originalName?: string; size: number; created_at: string; createdAt?: string }>>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadFiles = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/inbox/files');
|
||||||
|
if (!res.ok) throw new Error('Ошибка загрузки');
|
||||||
|
const data = await res.json();
|
||||||
|
setFiles(data.map((f: { id: string; original_name: string; originalName?: string; size: number; created_at: string; createdAt?: string }) => ({
|
||||||
|
id: f.id,
|
||||||
|
original_name: f.original_name || f.originalName,
|
||||||
|
size: f.size,
|
||||||
|
created_at: f.created_at || f.createdAt,
|
||||||
|
})));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Ошибка');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<div id="main-content" role="main" style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<Logo size="large" />
|
||||||
|
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||||
|
Inbox — загрузка и обработка документов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>AI Inbox</h2>
|
||||||
|
<p style={{ color: '#666', marginBottom: '24px' }}>
|
||||||
|
Загрузите PDF или DOCX для извлечения данных. API: FastAPI /api/v1/inbox или Express inbox-server.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={loadFiles}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Загрузка...' : 'Обновить список файлов'}
|
||||||
|
</button>
|
||||||
|
{error && <div style={{ color: 'red', marginTop: '16px' }}>{error}</div>}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<ul style={{ marginTop: '24px', listStyle: 'none', padding: 0 }}>
|
||||||
|
{files.map((f) => (
|
||||||
|
<li key={f.id} style={{ padding: '12px', borderBottom: '1px solid #eee', display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span>{f.original_name}</span>
|
||||||
|
<span style={{ color: '#666' }}>{(f.size / 1024).toFixed(1)} KB</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
351
app/jira-tasks/page.tsx
Normal file
351
app/jira-tasks/page.tsx
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
/**
|
||||||
|
* Страница для просмотра задач из Jira
|
||||||
|
* Данные импортируются из CSV файлов в папке "новая папка"
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
|
||||||
|
interface JiraEpic {
|
||||||
|
issueId: string;
|
||||||
|
summary: string;
|
||||||
|
description: string;
|
||||||
|
priority: string;
|
||||||
|
components: string[];
|
||||||
|
labels: string[];
|
||||||
|
stories?: JiraStory[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JiraStory {
|
||||||
|
issueId: string;
|
||||||
|
summary: string;
|
||||||
|
description: string;
|
||||||
|
priority: string;
|
||||||
|
storyPoints?: number;
|
||||||
|
components: string[];
|
||||||
|
labels: string[];
|
||||||
|
acceptanceCriteria?: string;
|
||||||
|
subtasks?: JiraSubtask[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JiraSubtask {
|
||||||
|
issueId: string;
|
||||||
|
summary: string;
|
||||||
|
description: string;
|
||||||
|
priority: string;
|
||||||
|
components: string[];
|
||||||
|
labels: string[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Dependency {
|
||||||
|
fromIssueId: string;
|
||||||
|
toIssueId: string;
|
||||||
|
linkType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JiraTasksPage() {
|
||||||
|
const [epics, setEpics] = useState<JiraEpic[]>([]);
|
||||||
|
const [dependencies, setDependencies] = useState<Dependency[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedEpic, setSelectedEpic] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTasks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadTasks = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await fetch('/api/jira-tasks?type=epic');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setEpics(data.data || []);
|
||||||
|
setDependencies(data.dependencies || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Ошибка загрузки задач');
|
||||||
|
console.error('Ошибка загрузки задач:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: string) => {
|
||||||
|
switch (priority?.toLowerCase()) {
|
||||||
|
case 'high':
|
||||||
|
return '#f44336';
|
||||||
|
case 'medium':
|
||||||
|
return '#ff9800';
|
||||||
|
case 'low':
|
||||||
|
return '#4caf50';
|
||||||
|
default:
|
||||||
|
return '#757575';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityLabel = (priority: string) => {
|
||||||
|
switch (priority?.toLowerCase()) {
|
||||||
|
case 'high':
|
||||||
|
return 'Высокий';
|
||||||
|
case 'medium':
|
||||||
|
return 'Средний';
|
||||||
|
case 'low':
|
||||||
|
return 'Низкий';
|
||||||
|
default:
|
||||||
|
return priority || 'Не указан';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
|
<div>Загрузка задач...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px' }}>
|
||||||
|
<div style={{ color: '#f44336', marginBottom: '20px' }}>
|
||||||
|
❌ Ошибка: {error}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadTasks}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Повторить загрузку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<div style={{ flex: 1, padding: '20px', marginLeft: '250px' }}>
|
||||||
|
<div style={{ marginBottom: '30px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h1 style={{ fontSize: '28px', fontWeight: 'bold', margin: '0 0 10px 0' }}>
|
||||||
|
Задачи Jira (REFLY)
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: '#666', margin: 0 }}>
|
||||||
|
Эпики, истории и подзадачи из импортированных CSV файлов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadTasks}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔄 Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{epics.length === 0 ? (
|
||||||
|
<div style={{ padding: '40px', textAlign: 'center', color: '#666' }}>
|
||||||
|
<p>Нет данных. Запустите импорт:</p>
|
||||||
|
<code style={{ display: 'block', marginTop: '10px', padding: '10px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||||
|
npm run import:jira
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
|
{epics.map((epic) => (
|
||||||
|
<div
|
||||||
|
key={epic.issueId}
|
||||||
|
style={{
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '20px',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '15px' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '8px' }}>
|
||||||
|
<span style={{ fontWeight: 'bold', fontSize: '18px' }}>📋 {epic.summary}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
backgroundColor: getPriorityColor(epic.priority),
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getPriorityLabel(epic.priority)}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: '#666', fontSize: '14px' }}>ID: {epic.issueId}</span>
|
||||||
|
</div>
|
||||||
|
{epic.description && (
|
||||||
|
<p style={{ color: '#666', margin: '8px 0', fontSize: '14px' }}>{epic.description}</p>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap', marginTop: '10px' }}>
|
||||||
|
{epic.components && epic.components.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<strong>Компоненты:</strong> {epic.components.join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{epic.labels && epic.labels.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<strong>Метки:</strong>{' '}
|
||||||
|
{epic.labels.map((label) => (
|
||||||
|
<span
|
||||||
|
key={label}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '2px 6px',
|
||||||
|
backgroundColor: '#e3f2fd',
|
||||||
|
borderRadius: '3px',
|
||||||
|
fontSize: '12px',
|
||||||
|
marginLeft: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedEpic(selectedEpic === epic.issueId ? null : epic.issueId)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: selectedEpic === epic.issueId ? '#1e3a5f' : '#f5f5f5',
|
||||||
|
color: selectedEpic === epic.issueId ? 'white' : '#333',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedEpic === epic.issueId ? 'Скрыть' : 'Показать'} истории
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedEpic === epic.issueId && epic.stories && epic.stories.length > 0 && (
|
||||||
|
<div style={{ marginTop: '20px', paddingTop: '20px', borderTop: '1px solid #e0e0e0' }}>
|
||||||
|
<h3 style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '15px' }}>
|
||||||
|
Истории ({epic.stories.length})
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
|
||||||
|
{epic.stories.map((story) => (
|
||||||
|
<div
|
||||||
|
key={story.issueId}
|
||||||
|
style={{
|
||||||
|
padding: '15px',
|
||||||
|
backgroundColor: '#f9f9f9',
|
||||||
|
borderRadius: '6px',
|
||||||
|
borderLeft: '4px solid #2196f3',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px' }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '5px' }}>
|
||||||
|
<span style={{ fontWeight: 'bold' }}>📝 {story.summary}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
backgroundColor: getPriorityColor(story.priority),
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '11px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getPriorityLabel(story.priority)}
|
||||||
|
</span>
|
||||||
|
{story.storyPoints && (
|
||||||
|
<span style={{ fontSize: '12px', color: '#666' }}>
|
||||||
|
{story.storyPoints} SP
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: '12px', color: '#999' }}>ID: {story.issueId}</span>
|
||||||
|
</div>
|
||||||
|
{story.description && (
|
||||||
|
<p style={{ color: '#666', fontSize: '13px', margin: '5px 0' }}>{story.description}</p>
|
||||||
|
)}
|
||||||
|
{story.acceptanceCriteria && (
|
||||||
|
<div style={{ marginTop: '8px', padding: '8px', backgroundColor: '#fff3cd', borderRadius: '4px' }}>
|
||||||
|
<strong style={{ fontSize: '12px' }}>Критерии приемки:</strong>
|
||||||
|
<pre style={{ fontSize: '12px', margin: '5px 0 0 0', whiteSpace: 'pre-wrap' }}>
|
||||||
|
{story.acceptanceCriteria}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{story.subtasks && story.subtasks.length > 0 && (
|
||||||
|
<div style={{ marginTop: '10px', paddingTop: '10px', borderTop: '1px solid #ddd' }}>
|
||||||
|
<strong style={{ fontSize: '12px' }}>Подзадачи ({story.subtasks.length}):</strong>
|
||||||
|
<ul style={{ margin: '5px 0', paddingLeft: '20px' }}>
|
||||||
|
{story.subtasks.map((subtask) => (
|
||||||
|
<li key={subtask.issueId} style={{ fontSize: '12px', margin: '3px 0' }}>
|
||||||
|
{subtask.summary} <span style={{ color: '#999' }}>({subtask.issueId})</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dependencies.length > 0 && (
|
||||||
|
<div style={{ marginTop: '30px', padding: '20px', backgroundColor: '#f5f5f5', borderRadius: '8px' }}>
|
||||||
|
<h2 style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '15px' }}>
|
||||||
|
Зависимости между задачами ({dependencies.length})
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '10px' }}>
|
||||||
|
{dependencies.map((dep, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
padding: '10px',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{dep.fromIssueId}</strong> → <strong>{dep.toIssueId}</strong>
|
||||||
|
<span style={{ color: '#666', marginLeft: '8px' }}>({dep.linkType})</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
app/layout.tsx
Normal file
25
app/layout.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
import './globals.css'
|
||||||
|
import { Providers } from './providers'
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'REFLY - Контроль лётной годности',
|
||||||
|
description: 'Система контроля лётной годности воздушных судов',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="ru">
|
||||||
|
<body>
|
||||||
|
<Providers>
|
||||||
|
{children}
|
||||||
|
</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
223
app/monitoring/page.tsx
Normal file
223
app/monitoring/page.tsx
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
|
||||||
|
interface HealthStatus {
|
||||||
|
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||||
|
checks: {
|
||||||
|
database: { status: 'ok' | 'error'; message?: string };
|
||||||
|
redis: { status: 'ok' | 'error'; message?: string };
|
||||||
|
disk: { status: 'ok' | 'error'; message?: string; freeSpace?: number };
|
||||||
|
};
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MonitoringPage() {
|
||||||
|
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||||
|
const [metrics, setMetrics] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
const interval = setInterval(loadData, 30000); // Обновление каждые 30 секунд
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [healthRes, metricsRes] = await Promise.all([
|
||||||
|
fetch('/api/health').catch(err => {
|
||||||
|
console.warn('[Monitoring] Ошибка загрузки health:', err);
|
||||||
|
return { ok: false, json: async () => ({ status: 'unhealthy', checks: {}, timestamp: new Date() }) };
|
||||||
|
}),
|
||||||
|
fetch('/api/metrics?type=performance').catch(err => {
|
||||||
|
console.warn('[Monitoring] Ошибка загрузки metrics:', err);
|
||||||
|
return { ok: false, json: async () => ({ stats: { count: 0, avgDuration: 0, minDuration: 0, maxDuration: 0, errorRate: 0 } }) };
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const healthData = await healthRes.json();
|
||||||
|
const metricsData = await metricsRes.json();
|
||||||
|
|
||||||
|
console.log('[Monitoring] Данные загружены:', { health: healthData.status, metrics: metricsData.stats?.count || 0 });
|
||||||
|
setHealth(healthData);
|
||||||
|
setMetrics(metricsData);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Monitoring] Ошибка загрузки данных мониторинга:', error);
|
||||||
|
setHealth(null);
|
||||||
|
setMetrics(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'healthy':
|
||||||
|
case 'ok':
|
||||||
|
return '#4caf50';
|
||||||
|
case 'degraded':
|
||||||
|
return '#ff9800';
|
||||||
|
case 'unhealthy':
|
||||||
|
case 'error':
|
||||||
|
return '#f44336';
|
||||||
|
default:
|
||||||
|
return '#666';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'healthy':
|
||||||
|
return 'Здоров';
|
||||||
|
case 'degraded':
|
||||||
|
return 'Работает (некритичные предупреждения)';
|
||||||
|
case 'unhealthy':
|
||||||
|
return 'Неисправен';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<div style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<h1 style={{ fontSize: '32px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
Мониторинг системы
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: '16px', color: '#666' }}>
|
||||||
|
Состояние системы и метрики производительности
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<div>Загрузка данных мониторинга...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Health Status */}
|
||||||
|
{health && (
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||||
|
Состояние системы
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: '24px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
borderLeft: `4px solid ${getStatusColor(health.status)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '4px' }}>
|
||||||
|
Общий статус: {getStatusText(health.status)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
Последняя проверка: {new Date(health.timestamp).toLocaleString('ru-RU')}
|
||||||
|
</div>
|
||||||
|
{health.status === 'degraded' && (
|
||||||
|
<div style={{ fontSize: '12px', color: '#ff9800', marginTop: '4px' }}>
|
||||||
|
⚠️ Система работает в режиме без БД/Redis (используются мок-данные)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
backgroundColor: getStatusColor(health.status),
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{health.status === 'degraded' ? 'DEGRADED' : health.status.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||||
|
{Object.entries(health.checks).map(([key, check]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
padding: '16px',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
borderRadius: '4px',
|
||||||
|
borderLeft: `3px solid ${getStatusColor(check.status)}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: 'bold', marginBottom: '4px', textTransform: 'capitalize' }}>
|
||||||
|
{key === 'database' ? 'База данных' : key === 'redis' ? 'Redis' : 'Диск'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: check.status === 'ok' ? '#4caf50' : '#f44336' }}>
|
||||||
|
{check.status === 'ok' ? '✓ Работает' : `✗ Ошибка: ${check.message || 'Неизвестная ошибка'}`}
|
||||||
|
</div>
|
||||||
|
{'freeSpace' in check && check.freeSpace !== undefined && (
|
||||||
|
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||||
|
Свободно: {check.freeSpace}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Performance Metrics */}
|
||||||
|
{metrics && metrics.stats && (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||||
|
Метрики производительности
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: '24px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||||
|
<div style={{ padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>Всего запросов</div>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>{metrics.stats.count}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>Среднее время ответа</div>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>
|
||||||
|
{metrics.stats.avgDuration.toFixed(0)}ms
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>Минимальное время</div>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>{metrics.stats.minDuration}ms</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>Максимальное время</div>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>{metrics.stats.maxDuration}ms</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '16px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>Процент ошибок</div>
|
||||||
|
<div style={{ fontSize: '24px', fontWeight: 'bold', color: metrics.stats.errorRate > 0.1 ? '#f44336' : '#4caf50' }}>
|
||||||
|
{(metrics.stats.errorRate * 100).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
331
app/organizations/page.tsx
Normal file
331
app/organizations/page.tsx
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import OrganizationDetailsModal from '@/components/OrganizationDetailsModal';
|
||||||
|
import OrganizationCreateModal from '@/components/OrganizationCreateModal';
|
||||||
|
import OrganizationEditModal from '@/components/OrganizationEditModal';
|
||||||
|
import SearchModal from '@/components/SearchModal';
|
||||||
|
import Logo from '@/components/Logo';
|
||||||
|
import { aircraftApi, Aircraft } from '@/lib/api';
|
||||||
|
|
||||||
|
export default function OrganizationsPage() {
|
||||||
|
const [aircraft, setAircraft] = useState<Aircraft[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [operators, setOperators] = useState<Map<string, Aircraft[]>>(new Map());
|
||||||
|
const [selectedOrganization, setSelectedOrganization] = useState<string | null>(null);
|
||||||
|
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||||
|
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
||||||
|
const [searchOrganization, setSearchOrganization] = useState<string | null>(null);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
|
const [editingOrganization, setEditingOrganization] = useState<{ name: string; type?: string; address?: string; contact?: string; email?: string; phone?: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const data = await aircraftApi.getAircraft();
|
||||||
|
setAircraft(data);
|
||||||
|
|
||||||
|
// Группируем по операторам
|
||||||
|
const operatorsMap = new Map<string, Aircraft[]>();
|
||||||
|
data.forEach(a => {
|
||||||
|
const operator = a.operator && a.operator !== 'Не указан' ? a.operator : 'Не указан';
|
||||||
|
if (!operatorsMap.has(operator)) {
|
||||||
|
operatorsMap.set(operator, []);
|
||||||
|
}
|
||||||
|
operatorsMap.get(operator)!.push(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
setOperators(operatorsMap);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки данных:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const operatorsList = Array.from(operators.entries()).sort((a, b) => b[1].length - a[1].length);
|
||||||
|
|
||||||
|
const handleShowDetails = (operator: string) => {
|
||||||
|
setSelectedOrganization(operator);
|
||||||
|
setIsDetailsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (operator: string) => {
|
||||||
|
setSearchOrganization(operator);
|
||||||
|
setIsSearchModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditAircraft = (editedAircraft: Aircraft) => {
|
||||||
|
// Обновляем данные в состоянии
|
||||||
|
setAircraft(prev => prev.map(a => a.id === editedAircraft.id ? editedAircraft : a));
|
||||||
|
|
||||||
|
// Обновляем группировку по операторам
|
||||||
|
const operatorsMap = new Map<string, Aircraft[]>();
|
||||||
|
const updatedAircraft = aircraft.map(a => a.id === editedAircraft.id ? editedAircraft : a);
|
||||||
|
updatedAircraft.forEach(a => {
|
||||||
|
const operator = a.operator && a.operator !== 'Не указан' ? a.operator : 'Не указан';
|
||||||
|
if (!operatorsMap.has(operator)) {
|
||||||
|
operatorsMap.set(operator, []);
|
||||||
|
}
|
||||||
|
operatorsMap.get(operator)!.push(a);
|
||||||
|
});
|
||||||
|
setOperators(operatorsMap);
|
||||||
|
|
||||||
|
alert(`Данные воздушного судна ${editedAircraft.registrationNumber} обновлены`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOrganizationAircraft = (operator: string): Aircraft[] => {
|
||||||
|
return operators.get(operator) || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSearchAircraft = (): Aircraft[] => {
|
||||||
|
// Если searchOrganization null, ищем по всем aircraft
|
||||||
|
// Если указана организация, ищем только в её aircraft
|
||||||
|
if (searchOrganization === null) {
|
||||||
|
return aircraft;
|
||||||
|
}
|
||||||
|
return operators.get(searchOrganization) || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateOrganization = (organizationData: any) => {
|
||||||
|
// Здесь можно добавить логику сохранения новой организации
|
||||||
|
// Пока просто показываем уведомление
|
||||||
|
alert(`Организация "${organizationData.name}" успешно создана`);
|
||||||
|
// В реальном приложении здесь был бы вызов API для сохранения
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (operator: string) => {
|
||||||
|
// Создаём объект организации из названия оператора
|
||||||
|
setEditingOrganization({
|
||||||
|
name: operator,
|
||||||
|
type: 'Авиакомпания', // По умолчанию, можно будет изменить в форме
|
||||||
|
});
|
||||||
|
setIsEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveOrganization = (updatedOrganization: any) => {
|
||||||
|
// Здесь можно добавить логику обновления организации
|
||||||
|
// Пока просто показываем уведомление
|
||||||
|
alert(`Организация "${updatedOrganization.name}" успешно обновлена`);
|
||||||
|
// В реальном приложении здесь был бы вызов API для обновления
|
||||||
|
// Также нужно обновить названия операторов в aircraft, если изменилось название организации
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<div style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<Logo size="large" />
|
||||||
|
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||||
|
Система контроля лётной годности воздушных судов · Безопасность и качество
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
Организации
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
Управление организациями и операторами воздушных судов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchOrganization(null);
|
||||||
|
setIsSearchModalOpen(true);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#2196f3',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Поиск
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Добавить организацию
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<div>Загрузка данных...</div>
|
||||||
|
</div>
|
||||||
|
) : operatorsList.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
{operatorsList.map(([operator, aircraftList]) => (
|
||||||
|
<div key={operator} style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: '24px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '4px' }}>
|
||||||
|
{operator}
|
||||||
|
</h3>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
{aircraftList.length} воздушных судов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSearch(operator)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#2196f3',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Поиск
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleShowDetails(operator)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#e0e0e0',
|
||||||
|
color: '#333',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Подробнее
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(operator)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||||
|
gap: '12px',
|
||||||
|
}}>
|
||||||
|
{aircraftList.slice(0, 5).map(a => (
|
||||||
|
<div key={a.id} style={{
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: '500' }}>{a.registrationNumber}</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#666' }}>{a.aircraftType}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{aircraftList.length > 5 && (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px',
|
||||||
|
backgroundColor: '#e3f2fd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
+{aircraftList.length - 5} ещё
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#e3f2fd',
|
||||||
|
padding: '20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start' }}>
|
||||||
|
<span style={{ fontSize: '20px', marginRight: '12px' }}>ℹ️</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>Нет данных</div>
|
||||||
|
<div style={{ fontSize: '14px' }}>
|
||||||
|
Организации не найдены. Проверьте данные реестра.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<OrganizationDetailsModal
|
||||||
|
isOpen={isDetailsModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsDetailsModalOpen(false);
|
||||||
|
setSelectedOrganization(null);
|
||||||
|
}}
|
||||||
|
organization={selectedOrganization || ''}
|
||||||
|
aircraft={selectedOrganization ? getOrganizationAircraft(selectedOrganization) : []}
|
||||||
|
onEdit={handleEditAircraft}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SearchModal
|
||||||
|
isOpen={isSearchModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsSearchModalOpen(false);
|
||||||
|
setSearchOrganization(null);
|
||||||
|
}}
|
||||||
|
aircraft={getSearchAircraft()}
|
||||||
|
searchType="organization"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OrganizationCreateModal
|
||||||
|
isOpen={isCreateModalOpen}
|
||||||
|
onClose={() => setIsCreateModalOpen(false)}
|
||||||
|
onCreate={handleCreateOrganization}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OrganizationEditModal
|
||||||
|
isOpen={isEditModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsEditModalOpen(false);
|
||||||
|
setEditingOrganization(null);
|
||||||
|
}}
|
||||||
|
organization={editingOrganization}
|
||||||
|
onSave={handleSaveOrganization}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
app/page.tsx
Normal file
18
app/page.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// app/page.tsx
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<main style={{ padding: 24, maxWidth: 900, margin: "0 auto" }}>
|
||||||
|
<h1 style={{ fontSize: 32, fontWeight: 700 }}>Numerology App</h1>
|
||||||
|
<p style={{ marginTop: 12, fontSize: 16 }}>
|
||||||
|
Главная страница подключена. Дальше сюда можно перенести ваш калькулятор
|
||||||
|
и отчёт (express / углубленный / полный).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<Link href="/dashboard">Перейти к дашборду</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
app/providers.tsx
Normal file
20
app/providers.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Провайдеры для приложения
|
||||||
|
* Включает Error Boundary и другие глобальные провайдеры
|
||||||
|
*/
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||||
|
import SkipToMain from '@/components/SkipToMain';
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SkipToMain />
|
||||||
|
<ErrorBoundary>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
app/regulations/page.tsx
Normal file
192
app/regulations/page.tsx
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import Logo from '@/components/Logo';
|
||||||
|
import RegulationViewModal from '@/components/RegulationViewModal';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
// ISR: revalidate работает только в серверных компонентах, не в клиентских
|
||||||
|
// export const revalidate = 3600; // Удалено, так как это клиентский компонент
|
||||||
|
|
||||||
|
import { RegulationDocument } from '@/lib/regulations';
|
||||||
|
|
||||||
|
type Regulation = RegulationDocument;
|
||||||
|
|
||||||
|
export default function RegulationsPage() {
|
||||||
|
const [selectedRegulation, setSelectedRegulation] = useState<Regulation | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [regulations, setRegulations] = useState<Regulation[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Загрузка данных с кэшированием
|
||||||
|
useEffect(() => {
|
||||||
|
const loadRegulations = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch('/api/regulations');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('[Regulations] API response:', {
|
||||||
|
isArray: Array.isArray(data),
|
||||||
|
hasDocuments: !!data?.documents,
|
||||||
|
documentsLength: data?.documents?.length || 0,
|
||||||
|
total: data?.total
|
||||||
|
});
|
||||||
|
// API возвращает объект с documents и total
|
||||||
|
const regulationsArray = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: (data?.documents || data?.data || []);
|
||||||
|
console.log('[Regulations] Processed regulations:', regulationsArray.length);
|
||||||
|
setRegulations(regulationsArray);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки нормативных документов:', error);
|
||||||
|
setRegulations([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadRegulations();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewRegulation = (regulation: Regulation) => {
|
||||||
|
setSelectedRegulation(regulation);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const regulationsBySource = regulations.reduce((acc, reg) => {
|
||||||
|
if (!acc[reg.source]) {
|
||||||
|
acc[reg.source] = [];
|
||||||
|
}
|
||||||
|
acc[reg.source].push(reg);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, Regulation[]>);
|
||||||
|
|
||||||
|
// Логирование для отладки
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[Regulations] State:', {
|
||||||
|
loading,
|
||||||
|
regulationsCount: regulations.length,
|
||||||
|
sourcesCount: Object.keys(regulationsBySource).length,
|
||||||
|
sources: Object.keys(regulationsBySource),
|
||||||
|
});
|
||||||
|
}, [loading, regulations.length, regulationsBySource]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<div id="main-content" role="main" style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<Logo size="large" />
|
||||||
|
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||||
|
Нормативные документы и стандарты гражданской авиации
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
Нормативные документы
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
Международные и российские нормативные документы по гражданской авиации
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<div style={{ fontSize: '16px', color: '#666' }}>Загрузка документов...</div>
|
||||||
|
</div>
|
||||||
|
) : regulations.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||||
|
<div style={{ fontSize: '16px', color: '#666', marginBottom: '16px' }}>Нормативные документы не найдены</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#999', marginBottom: '16px' }}>
|
||||||
|
Загружено: {regulations.length} документов
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch('/api/regulations')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
console.log('[Regulations] Reload response:', data);
|
||||||
|
const regulationsArray = Array.isArray(data) ? data : (data?.documents || data?.data || []);
|
||||||
|
setRegulations(regulationsArray);
|
||||||
|
})
|
||||||
|
.catch(err => console.error('Ошибка загрузки:', err))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#2196f3',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Повторить загрузку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||||
|
{Object.entries(regulationsBySource).map(([source, sourceRegulations]) => (
|
||||||
|
<div key={source} style={{ backgroundColor: 'white', borderRadius: '8px', padding: '24px', boxShadow: '0 2px 4px rgba(0,0,0,0.1)' }}>
|
||||||
|
<h3 style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '16px' }}>
|
||||||
|
{source}
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '12px' }}>
|
||||||
|
{sourceRegulations.map((regulation) => (
|
||||||
|
<div
|
||||||
|
key={regulation.id}
|
||||||
|
onClick={() => handleViewRegulation(regulation)}
|
||||||
|
style={{
|
||||||
|
padding: '16px',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 0.2s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = '#f5f5f5';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = 'white';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: '500', marginBottom: '4px' }}>
|
||||||
|
{regulation.title}
|
||||||
|
</div>
|
||||||
|
{regulation.version && (
|
||||||
|
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||||
|
Версия: {regulation.version}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{regulation.lastUpdated && (
|
||||||
|
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||||
|
Дата: {new Date(regulation.lastUpdated).toLocaleDateString('ru-RU')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedRegulation && (
|
||||||
|
<RegulationViewModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setSelectedRegulation(null);
|
||||||
|
}}
|
||||||
|
document={selectedRegulation}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
280
app/risks/page.tsx
Normal file
280
app/risks/page.tsx
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import RiskDetailsModal from '@/components/RiskDetailsModal';
|
||||||
|
import Logo from '@/components/Logo';
|
||||||
|
|
||||||
|
interface Risk {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
level: 'Низкий' | 'Средний' | 'Высокий' | 'Критический';
|
||||||
|
category: string;
|
||||||
|
aircraft: string;
|
||||||
|
status: string;
|
||||||
|
date: string;
|
||||||
|
description?: string;
|
||||||
|
impact?: string;
|
||||||
|
probability?: string;
|
||||||
|
mitigation?: string;
|
||||||
|
responsible?: string;
|
||||||
|
deadline?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RisksPage() {
|
||||||
|
const [filter, setFilter] = useState<'all' | 'critical' | 'high'>('all');
|
||||||
|
const [risks, setRisks] = useState<Risk[]>([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Превышение межремонтного ресурса',
|
||||||
|
level: 'Высокий',
|
||||||
|
category: 'Техническое состояние',
|
||||||
|
aircraft: 'RA-12345',
|
||||||
|
status: 'Требует внимания',
|
||||||
|
date: '2025-01-20',
|
||||||
|
description: 'Воздушное судно RA-12345 превысило установленный межремонтный ресурс на 150 часов. Требуется немедленное проведение планового технического обслуживания для обеспечения безопасности полетов.',
|
||||||
|
impact: 'Высокое влияние на безопасность полетов. Возможны ограничения на эксплуатацию воздушного судна до проведения ремонта.',
|
||||||
|
probability: 'Высокая вероятность возникновения инцидентов при продолжении эксплуатации без ремонта.',
|
||||||
|
mitigation: '1. Немедленно ограничить эксплуатацию воздушного судна\n2. Назначить ответственного за проведение ремонта\n3. Провести плановое техническое обслуживание в течение 7 дней\n4. Обновить документацию после завершения ремонта',
|
||||||
|
responsible: 'Иванов И.И., главный инженер',
|
||||||
|
deadline: '2025-01-27',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Несоответствие документации',
|
||||||
|
level: 'Средний',
|
||||||
|
category: 'Документация',
|
||||||
|
aircraft: 'RA-67890',
|
||||||
|
status: 'В работе',
|
||||||
|
date: '2025-01-19',
|
||||||
|
description: 'Обнаружены расхождения между фактическим состоянием воздушного судна и документацией. Отсутствуют записи о последнем техническом обслуживании.',
|
||||||
|
impact: 'Среднее влияние. Может привести к задержкам при проверках и аудитах.',
|
||||||
|
probability: 'Средняя вероятность возникновения проблем при проверках.',
|
||||||
|
mitigation: '1. Провести инвентаризацию документации\n2. Восстановить недостающие записи\n3. Согласовать документацию с фактическим состоянием\n4. Внедрить систему контроля документации',
|
||||||
|
responsible: 'Петров П.П., специалист по документации',
|
||||||
|
deadline: '2025-01-25',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: 'Критическая неисправность системы управления',
|
||||||
|
level: 'Критический',
|
||||||
|
category: 'Техническое состояние',
|
||||||
|
aircraft: 'RA-11111',
|
||||||
|
status: 'Требует внимания',
|
||||||
|
date: '2025-01-21',
|
||||||
|
description: 'Обнаружена критическая неисправность в системе управления воздушным судном. Требуется немедленное устранение.',
|
||||||
|
impact: 'Критическое влияние на безопасность полетов. Эксплуатация воздушного судна запрещена до устранения неисправности.',
|
||||||
|
probability: 'Очень высокая вероятность аварии при продолжении эксплуатации.',
|
||||||
|
mitigation: '1. Немедленно запретить эксплуатацию воздушного судна\n2. Провести полную диагностику системы управления\n3. Заменить неисправные компоненты\n4. Провести тестовые полеты после ремонта',
|
||||||
|
responsible: 'Сидоров С.С., главный инженер',
|
||||||
|
deadline: '2025-01-23',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [selectedRisk, setSelectedRisk] = useState<Risk | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleSaveRisk = (updatedRisk: Risk) => {
|
||||||
|
setRisks(prev => prev.map(r => r.id === updatedRisk.id ? updatedRisk : r));
|
||||||
|
setSelectedRisk(updatedRisk);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredRisks = risks.filter(risk => {
|
||||||
|
if (filter === 'all') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (filter === 'critical') {
|
||||||
|
return risk.level === 'Критический';
|
||||||
|
}
|
||||||
|
if (filter === 'high') {
|
||||||
|
return risk.level === 'Высокий';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getLevelColor = (level: string) => {
|
||||||
|
switch (level) {
|
||||||
|
case 'Критический': return '#f44336';
|
||||||
|
case 'Высокий': return '#ff9800';
|
||||||
|
case 'Средний': return '#ffc107';
|
||||||
|
case 'Низкий': return '#4caf50';
|
||||||
|
default: return '#666';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<div id="main-content" role="main" style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<Logo size="large" />
|
||||||
|
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||||
|
Система контроля лётной годности воздушных судов · Безопасность и качество
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
Риски
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
Управление рисками и оценка безопасности воздушных судов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
Добавить риск
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px', display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('all')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: filter === 'all' ? '#1e3a5f' : 'transparent',
|
||||||
|
color: filter === 'all' ? 'white' : '#1e3a5f',
|
||||||
|
border: '1px solid #1e3a5f',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Все
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('critical')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: filter === 'critical' ? '#f44336' : 'transparent',
|
||||||
|
color: filter === 'critical' ? 'white' : '#1e3a5f',
|
||||||
|
border: '1px solid #1e3a5f',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Критические
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('high')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: filter === 'high' ? '#ff9800' : 'transparent',
|
||||||
|
color: filter === 'high' ? 'white' : '#1e3a5f',
|
||||||
|
border: '1px solid #1e3a5f',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Высокие
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredRisks.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#e3f2fd',
|
||||||
|
padding: '40px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
{filter === 'critical' && 'Критические риски не найдены'}
|
||||||
|
{filter === 'high' && 'Высокие риски не найдены'}
|
||||||
|
{filter === 'all' && 'Риски не найдены'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
{filter === 'critical' && 'В данный момент нет критических рисков'}
|
||||||
|
{filter === 'high' && 'В данный момент нет высоких рисков'}
|
||||||
|
{filter === 'all' && 'Добавьте риски для отображения'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||||
|
{filteredRisks.map(risk => (
|
||||||
|
<div key={risk.id} style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
padding: '24px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
borderLeft: `4px solid ${getLevelColor(risk.level)}`,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
{risk.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666', marginBottom: '4px' }}>
|
||||||
|
Категория: {risk.category} | ВС: {risk.aircraft}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
Дата выявления: {risk.date}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<span style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
backgroundColor: getLevelColor(risk.level),
|
||||||
|
color: 'white',
|
||||||
|
}}>
|
||||||
|
{risk.level}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
backgroundColor: '#e0e0e0',
|
||||||
|
color: '#333',
|
||||||
|
}}>
|
||||||
|
{risk.status}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedRisk(risk);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Открыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RiskDetailsModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setSelectedRisk(null);
|
||||||
|
}}
|
||||||
|
risk={selectedRisk}
|
||||||
|
onSave={handleSaveRisk}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
app/users/page.tsx
Normal file
212
app/users/page.tsx
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Sidebar from '@/components/Sidebar';
|
||||||
|
import UserEditModal from '@/components/UserEditModal';
|
||||||
|
import Logo from '@/components/Logo';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
status: string;
|
||||||
|
lastLogin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const [users, setUsers] = useState<User[]>([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Иванов Иван Иванович',
|
||||||
|
email: 'ivanov@klg.ru',
|
||||||
|
role: 'Администратор',
|
||||||
|
status: 'Активен',
|
||||||
|
lastLogin: '2025-01-21 10:30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Петров Петр Петрович',
|
||||||
|
email: 'petrov@klg.ru',
|
||||||
|
role: 'Инспектор',
|
||||||
|
status: 'Активен',
|
||||||
|
lastLogin: '2025-01-21 09:15',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Сидоров Сидор Сидорович',
|
||||||
|
email: 'sidorov@klg.ru',
|
||||||
|
role: 'Оператор',
|
||||||
|
status: 'Неактивен',
|
||||||
|
lastLogin: '2025-01-20 16:45',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleEdit = (user: User) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setIsEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveUser = (updatedUser: User) => {
|
||||||
|
setUsers(prev => prev.map(u => u.id === updatedUser.id ? updatedUser : u));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
|
<Sidebar />
|
||||||
|
<div style={{ marginLeft: '280px', flex: 1, padding: '32px' }}>
|
||||||
|
<div style={{ marginBottom: '32px' }}>
|
||||||
|
<Logo size="large" />
|
||||||
|
<p style={{ fontSize: '16px', color: '#666', marginTop: '16px', marginBottom: '24px' }}>
|
||||||
|
Система контроля лётной годности воздушных судов · Безопасность и качество
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '24px', fontWeight: 'bold', marginBottom: '8px' }}>
|
||||||
|
Пользователи
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
Управление пользователями системы
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
Добавить пользователя
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px', display: 'flex', gap: '8px' }}>
|
||||||
|
<button style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
Все
|
||||||
|
</button>
|
||||||
|
<button style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: '#1e3a5f',
|
||||||
|
border: '1px solid #1e3a5f',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
Активные
|
||||||
|
</button>
|
||||||
|
<button style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: '#1e3a5f',
|
||||||
|
border: '1px solid #1e3a5f',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}>
|
||||||
|
Администраторы
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ backgroundColor: '#1e3a5f', color: 'white' }}>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: 'bold' }}>
|
||||||
|
Имя
|
||||||
|
</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: 'bold' }}>
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: 'bold' }}>
|
||||||
|
Роль
|
||||||
|
</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: 'bold' }}>
|
||||||
|
Статус
|
||||||
|
</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: 'bold' }}>
|
||||||
|
Последний вход
|
||||||
|
</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left', fontSize: '12px', fontWeight: 'bold' }}>
|
||||||
|
Действия
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((user, index) => (
|
||||||
|
<tr key={user.id} style={{
|
||||||
|
borderBottom: '1px solid #e0e0e0',
|
||||||
|
backgroundColor: index % 2 === 0 ? 'white' : '#f9f9f9',
|
||||||
|
}}>
|
||||||
|
<td style={{ padding: '12px' }}>{user.name}</td>
|
||||||
|
<td style={{ padding: '12px' }}>{user.email}</td>
|
||||||
|
<td style={{ padding: '12px' }}>{user.role}</td>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
<span style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
backgroundColor: user.status === 'Активен' ? '#4caf50' : '#999',
|
||||||
|
color: 'white',
|
||||||
|
}}>
|
||||||
|
{user.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px', fontSize: '14px', color: '#666' }}>{user.lastLogin}</td>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(user)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
backgroundColor: '#1e3a5f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserEditModal
|
||||||
|
isOpen={isEditModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsEditModalOpen(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
}}
|
||||||
|
user={selectedUser}
|
||||||
|
onSave={handleSaveUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
backend/Dockerfile
Normal file
13
backend/Dockerfile
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Установка зависимостей
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Копирование кода
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Запуск приложения
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
115
backend/alembic.ini
Normal file
115
backend/alembic.ini
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts
|
||||||
|
script_location = alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python-dateutil library that can be
|
||||||
|
# installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to dateutil.tz.gettz()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the
|
||||||
|
# "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to alembic/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified
|
||||||
|
# by "version_path_separator" below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||||
|
|
||||||
|
# version path separator; As mentioned above, this is the character used to split
|
||||||
|
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||||
|
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||||
|
# Valid values for version_path_separator are:
|
||||||
|
#
|
||||||
|
# version_path_separator = :
|
||||||
|
# version_path_separator = ;
|
||||||
|
# version_path_separator = space
|
||||||
|
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||||
|
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
88
backend/alembic/env.py
Normal file
88
backend/alembic/env.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
from app.db.base import Base
|
||||||
|
from app import models # noqa: F401 - импортируем все модели для autogenerate
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def get_url():
|
||||||
|
"""Получает URL базы данных из настроек приложения."""
|
||||||
|
from app.core.config import settings
|
||||||
|
return settings.database_url
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = get_url()
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
configuration = config.get_section(config.config_ini_section)
|
||||||
|
configuration["sqlalchemy.url"] = get_url()
|
||||||
|
|
||||||
|
connectable = engine_from_config(
|
||||||
|
configuration,
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection, target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from app.core.config import settings # noqa: F401
|
||||||
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
83
backend/app/api/deps.py
Normal file
83
backend/app/api/deps.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import User
|
||||||
|
from app.services.security import decode_token, token_to_user, AuthError
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
# DEV bypass: включается ТОЛЬКО если ENABLE_DEV_AUTH=true
|
||||||
|
ENABLE_DEV_AUTH = os.getenv("ENABLE_DEV_AUTH", "false").strip().lower() == "true"
|
||||||
|
DEV_TOKEN = (os.getenv("DEV_TOKEN") or "dev").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dev_token(t: str) -> bool:
|
||||||
|
if not ENABLE_DEV_AUTH:
|
||||||
|
return False
|
||||||
|
return (t or "").strip().lower() == DEV_TOKEN
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
creds: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
if creds is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing Authorization header")
|
||||||
|
|
||||||
|
token = creds.credentials
|
||||||
|
|
||||||
|
# -------- DEV BYPASS (only when ENABLE_DEV_AUTH=true) --------
|
||||||
|
if _is_dev_token(token):
|
||||||
|
tu = type(
|
||||||
|
"TokenUser",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"sub": "dev",
|
||||||
|
"display_name": "Dev User",
|
||||||
|
"email": "dev@local",
|
||||||
|
"role": "admin",
|
||||||
|
"org_id": None,
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
claims = await decode_token(token)
|
||||||
|
except AuthError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
|
||||||
|
|
||||||
|
tu = token_to_user(claims)
|
||||||
|
# ----------------------------
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.external_subject == tu.sub).first()
|
||||||
|
if not user:
|
||||||
|
user = User(
|
||||||
|
external_subject=tu.sub,
|
||||||
|
display_name=tu.display_name,
|
||||||
|
email=tu.email,
|
||||||
|
role=tu.role,
|
||||||
|
organization_id=tu.org_id,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
else:
|
||||||
|
user.display_name = tu.display_name
|
||||||
|
user.email = tu.email
|
||||||
|
user.role = tu.role
|
||||||
|
user.organization_id = tu.org_id
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_roles(*roles: str):
|
||||||
|
def _dep(user=Depends(get_current_user)):
|
||||||
|
if user.role not in set(roles):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||||
|
return user
|
||||||
|
|
||||||
|
return _dep
|
||||||
73
backend/app/api/exceptions.py
Normal file
73
backend/app/api/exceptions.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
Централизованная обработка исключений для API.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from fastapi import Request, status
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError, IntegrityError
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def pydantic_validation_error_handler(request: Request, exc: ValidationError):
|
||||||
|
"""Ошибки валидации Pydantic при сериализации ответа (например, datetime из SQLite)."""
|
||||||
|
errs = exc.errors()
|
||||||
|
logger.warning("Pydantic ValidationError on %s: %s", request.url.path, errs)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
content={
|
||||||
|
"detail": "Ошибка формата данных. Подробности в логе бэкенда.",
|
||||||
|
"errors": errs,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||||
|
"""Обработчик ошибок валидации Pydantic."""
|
||||||
|
errors = exc.errors()
|
||||||
|
logger.warning(f"Validation error on {request.url.path}: {errors}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
content={
|
||||||
|
"detail": "Ошибка валидации данных",
|
||||||
|
"errors": errors,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def integrity_error_handler(request: Request, exc: IntegrityError):
|
||||||
|
"""Обработчик ошибок целостности БД (дубликаты, внешние ключи и т.д.)."""
|
||||||
|
logger.error(f"Database integrity error on {request.url.path}: {str(exc)}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
content={
|
||||||
|
"detail": "Нарушение целостности данных. Возможно, запись уже существует.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def sqlalchemy_error_handler(request: Request, exc: SQLAlchemyError):
|
||||||
|
"""Обработчик общих ошибок SQLAlchemy."""
|
||||||
|
logger.error(f"Database error on {request.url.path}: {str(exc)}", exc_info=True)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
content={
|
||||||
|
"detail": "Ошибка базы данных. Обратитесь к администратору.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def general_exception_handler(request: Request, exc: Exception):
|
||||||
|
"""Обработчик всех остальных исключений."""
|
||||||
|
logger.error(
|
||||||
|
f"Unhandled exception on {request.url.path}: {str(exc)}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
content={
|
||||||
|
"detail": "Внутренняя ошибка сервера. Обратитесь к администратору.",
|
||||||
|
},
|
||||||
|
)
|
||||||
38
backend/app/api/routes/__init__.py
Normal file
38
backend/app/api/routes/__init__.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
from .health import router as health_router
|
||||||
|
from .organizations import router as organizations_router
|
||||||
|
from .aircraft import router as aircraft_router
|
||||||
|
from .cert_applications import router as cert_applications_router
|
||||||
|
from .attachments import router as attachments_router
|
||||||
|
from .notifications import router as notifications_router
|
||||||
|
from .ingest import router as ingest_router
|
||||||
|
from .airworthiness import router as airworthiness_router
|
||||||
|
from .modifications import router as modifications_router
|
||||||
|
from .users import router as users_router
|
||||||
|
from .legal import router as legal_router
|
||||||
|
from .risk_alerts import router as risk_alerts_router
|
||||||
|
from .checklists import router as checklists_router
|
||||||
|
from .checklist_audits import router as checklist_audits_router
|
||||||
|
from .inbox import router as inbox_router
|
||||||
|
from .tasks import router as tasks_router
|
||||||
|
from .audit import router as audit_router
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"health_router",
|
||||||
|
"organizations_router",
|
||||||
|
"aircraft_router",
|
||||||
|
"cert_applications_router",
|
||||||
|
"attachments_router",
|
||||||
|
"notifications_router",
|
||||||
|
"ingest_router",
|
||||||
|
"airworthiness_router",
|
||||||
|
"modifications_router",
|
||||||
|
"users_router",
|
||||||
|
"legal_router",
|
||||||
|
"risk_alerts_router",
|
||||||
|
"checklists_router",
|
||||||
|
"checklist_audits_router",
|
||||||
|
"inbox_router",
|
||||||
|
"tasks_router",
|
||||||
|
"audit_router",
|
||||||
|
]
|
||||||
|
|
||||||
304
backend/app/api/routes/aircraft.py
Normal file
304
backend/app/api/routes/aircraft.py
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, require_roles
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import Aircraft, AircraftType
|
||||||
|
from app.schemas.aircraft import AircraftCreate, AircraftOut, AircraftUpdate, AircraftTypeCreate, AircraftTypeOut
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["aircraft"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/aircraft/types", response_model=list[AircraftTypeOut])
|
||||||
|
def list_types(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
try:
|
||||||
|
types = db.query(AircraftType).order_by(AircraftType.manufacturer, AircraftType.model).all()
|
||||||
|
result = []
|
||||||
|
for t in types:
|
||||||
|
try:
|
||||||
|
result.append(AircraftTypeOut.model_validate(t))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при сериализации типа ВС {t.id}: {str(e)}", exc_info=True)
|
||||||
|
continue
|
||||||
|
logger.info(f"Успешно возвращено {len(result)} типов ВС из {len(types)}")
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
logger.error(f"Ошибка при получении списка типов ВС: {str(e)}", exc_info=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Ошибка при получении списка типов ВС: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/aircraft/types",
|
||||||
|
response_model=AircraftTypeOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
def create_type(payload: AircraftTypeCreate, db: Session = Depends(get_db)):
|
||||||
|
t = AircraftType(**payload.model_dump())
|
||||||
|
db.add(t)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(t)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/aircraft", response_model=list[AircraftOut])
|
||||||
|
def list_aircraft(
|
||||||
|
q: str | None = Query(None, description="Search by registration number or drawing numbers"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
from app.models.organization import Organization
|
||||||
|
|
||||||
|
query = db.query(Aircraft).options(joinedload(Aircraft.aircraft_type))
|
||||||
|
# Operator-bound visibility
|
||||||
|
if user.role.startswith("operator") and user.organization_id:
|
||||||
|
query = query.filter(Aircraft.operator_id == user.organization_id)
|
||||||
|
if q:
|
||||||
|
# Поиск по регистрационному номеру или чертежному номеру
|
||||||
|
query = query.filter(
|
||||||
|
or_(
|
||||||
|
Aircraft.registration_number.ilike(f"%{q}%"),
|
||||||
|
Aircraft.drawing_numbers.ilike(f"%{q}%")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
aircraft_list = query.order_by(Aircraft.registration_number).all()
|
||||||
|
|
||||||
|
# Добавляем название организации-оператора
|
||||||
|
result = []
|
||||||
|
for a in aircraft_list:
|
||||||
|
try:
|
||||||
|
if not a.aircraft_type or not a.operator_id:
|
||||||
|
continue
|
||||||
|
operator_name = None
|
||||||
|
if a.operator_id:
|
||||||
|
org = db.query(Organization).filter(Organization.id == a.operator_id).first()
|
||||||
|
if org:
|
||||||
|
operator_name = org.name
|
||||||
|
|
||||||
|
aircraft_out = AircraftOut.model_validate({
|
||||||
|
"id": a.id,
|
||||||
|
"registration_number": a.registration_number,
|
||||||
|
"aircraft_type": {
|
||||||
|
"id": a.aircraft_type.id,
|
||||||
|
"manufacturer": a.aircraft_type.manufacturer,
|
||||||
|
"model": a.aircraft_type.model,
|
||||||
|
"created_at": a.aircraft_type.created_at,
|
||||||
|
"updated_at": a.aircraft_type.updated_at,
|
||||||
|
},
|
||||||
|
"operator_id": a.operator_id,
|
||||||
|
"operator_name": operator_name,
|
||||||
|
"serial_number": getattr(a, 'serial_number', None),
|
||||||
|
"manufacture_date": getattr(a, 'manufacture_date', None),
|
||||||
|
"first_flight_date": getattr(a, 'first_flight_date', None),
|
||||||
|
"total_time": float(getattr(a, 'total_time', 0)) if getattr(a, 'total_time', None) is not None else None,
|
||||||
|
"total_cycles": getattr(a, 'total_cycles', None),
|
||||||
|
"current_status": getattr(a, 'current_status', 'in_service') or "in_service",
|
||||||
|
"configuration": getattr(a, 'configuration', None),
|
||||||
|
"drawing_numbers": getattr(a, 'drawing_numbers', None),
|
||||||
|
"work_completion_date": getattr(a, 'work_completion_date', None),
|
||||||
|
"created_at": a.created_at,
|
||||||
|
"updated_at": a.updated_at,
|
||||||
|
})
|
||||||
|
result.append(aircraft_out)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при сериализации ВС {a.id}: {str(e)}", exc_info=True)
|
||||||
|
# Пропускаем проблемное ВС, но продолжаем обработку остальных
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Успешно возвращено {len(result)} ВС из {len(aircraft_list)}")
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
logger.error(f"Ошибка при получении списка ВС: {str(e)}", exc_info=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Ошибка при получении списка ВС: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/aircraft",
|
||||||
|
response_model=AircraftOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager"))],
|
||||||
|
)
|
||||||
|
def create_aircraft(payload: AircraftCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
if db.query(Aircraft).filter(Aircraft.registration_number == payload.registration_number).first():
|
||||||
|
raise HTTPException(status_code=409, detail="Already exists")
|
||||||
|
|
||||||
|
at = db.query(AircraftType).filter(AircraftType.id == payload.aircraft_type_id).first()
|
||||||
|
if not at:
|
||||||
|
raise HTTPException(status_code=404, detail="AircraftType not found")
|
||||||
|
|
||||||
|
operator_id = payload.operator_id
|
||||||
|
if user.role.startswith("operator"):
|
||||||
|
operator_id = user.organization_id
|
||||||
|
if not operator_id:
|
||||||
|
raise HTTPException(status_code=400, detail="operator_id is required")
|
||||||
|
|
||||||
|
a = Aircraft(
|
||||||
|
registration_number=payload.registration_number,
|
||||||
|
aircraft_type_id=payload.aircraft_type_id,
|
||||||
|
operator_id=operator_id,
|
||||||
|
serial_number=payload.serial_number,
|
||||||
|
manufacture_date=payload.manufacture_date,
|
||||||
|
first_flight_date=payload.first_flight_date,
|
||||||
|
total_time=payload.total_time,
|
||||||
|
total_cycles=payload.total_cycles,
|
||||||
|
current_status=payload.current_status,
|
||||||
|
configuration=payload.configuration,
|
||||||
|
drawing_numbers=payload.drawing_numbers,
|
||||||
|
work_completion_date=payload.work_completion_date,
|
||||||
|
)
|
||||||
|
db.add(a)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(a)
|
||||||
|
|
||||||
|
# Получаем название оператора
|
||||||
|
operator_name = None
|
||||||
|
if a.operator_id:
|
||||||
|
org = db.query(Organization).filter(Organization.id == a.operator_id).first()
|
||||||
|
if org:
|
||||||
|
operator_name = org.name
|
||||||
|
|
||||||
|
# Создаем объект AircraftOut с дополнительным полем operator_name
|
||||||
|
aircraft_out = AircraftOut.model_validate({
|
||||||
|
"id": a.id,
|
||||||
|
"registration_number": a.registration_number,
|
||||||
|
"aircraft_type": {
|
||||||
|
"id": a.aircraft_type.id,
|
||||||
|
"manufacturer": a.aircraft_type.manufacturer,
|
||||||
|
"model": a.aircraft_type.model,
|
||||||
|
"created_at": a.aircraft_type.created_at,
|
||||||
|
"updated_at": a.aircraft_type.updated_at,
|
||||||
|
},
|
||||||
|
"operator_id": a.operator_id,
|
||||||
|
"operator_name": operator_name,
|
||||||
|
"serial_number": getattr(a, 'serial_number', None),
|
||||||
|
"manufacture_date": getattr(a, 'manufacture_date', None),
|
||||||
|
"first_flight_date": getattr(a, 'first_flight_date', None),
|
||||||
|
"total_time": float(getattr(a, 'total_time', 0)) if getattr(a, 'total_time', None) is not None else None,
|
||||||
|
"total_cycles": getattr(a, 'total_cycles', None),
|
||||||
|
"current_status": getattr(a, 'current_status', 'in_service') or "in_service",
|
||||||
|
"configuration": getattr(a, 'configuration', None),
|
||||||
|
"drawing_numbers": getattr(a, 'drawing_numbers', None),
|
||||||
|
"work_completion_date": getattr(a, 'work_completion_date', None),
|
||||||
|
"created_at": a.created_at,
|
||||||
|
"updated_at": a.updated_at,
|
||||||
|
})
|
||||||
|
return aircraft_out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/aircraft/{aircraft_id}", response_model=AircraftOut)
|
||||||
|
def get_aircraft(aircraft_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
a = db.query(Aircraft).options(joinedload(Aircraft.aircraft_type)).filter(Aircraft.id == aircraft_id).first()
|
||||||
|
if not a:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
if user.role.startswith("operator") and user.organization_id and a.operator_id != user.organization_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
if not a.aircraft_type:
|
||||||
|
raise HTTPException(status_code=500, detail="Aircraft has no aircraft_type (data integrity)")
|
||||||
|
|
||||||
|
# Получаем название оператора
|
||||||
|
operator_name = None
|
||||||
|
if a.operator_id:
|
||||||
|
org = db.query(Organization).filter(Organization.id == a.operator_id).first()
|
||||||
|
if org:
|
||||||
|
operator_name = org.name
|
||||||
|
|
||||||
|
# Создаем объект AircraftOut с дополнительным полем operator_name
|
||||||
|
aircraft_out = AircraftOut.model_validate({
|
||||||
|
"id": a.id,
|
||||||
|
"registration_number": a.registration_number,
|
||||||
|
"aircraft_type": {
|
||||||
|
"id": a.aircraft_type.id,
|
||||||
|
"manufacturer": a.aircraft_type.manufacturer,
|
||||||
|
"model": a.aircraft_type.model,
|
||||||
|
"created_at": a.aircraft_type.created_at,
|
||||||
|
"updated_at": a.aircraft_type.updated_at,
|
||||||
|
},
|
||||||
|
"operator_id": a.operator_id,
|
||||||
|
"operator_name": operator_name,
|
||||||
|
"serial_number": getattr(a, 'serial_number', None),
|
||||||
|
"manufacture_date": getattr(a, 'manufacture_date', None),
|
||||||
|
"first_flight_date": getattr(a, 'first_flight_date', None),
|
||||||
|
"total_time": float(getattr(a, 'total_time', 0)) if getattr(a, 'total_time', None) is not None else None,
|
||||||
|
"total_cycles": getattr(a, 'total_cycles', None),
|
||||||
|
"current_status": getattr(a, 'current_status', 'in_service') or "in_service",
|
||||||
|
"configuration": getattr(a, 'configuration', None),
|
||||||
|
"drawing_numbers": getattr(a, 'drawing_numbers', None),
|
||||||
|
"work_completion_date": getattr(a, 'work_completion_date', None),
|
||||||
|
"created_at": a.created_at,
|
||||||
|
"updated_at": a.updated_at,
|
||||||
|
})
|
||||||
|
return aircraft_out
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/aircraft/{aircraft_id}",
|
||||||
|
response_model=AircraftOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager"))],
|
||||||
|
)
|
||||||
|
def update_aircraft(aircraft_id: str, payload: AircraftUpdate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
"""Обновить данные воздушного судна."""
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
a = db.query(Aircraft).options(joinedload(Aircraft.aircraft_type)).filter(Aircraft.id == aircraft_id).first()
|
||||||
|
if not a:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
if user.role.startswith("operator") and user.organization_id and a.operator_id != user.organization_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
if not a.aircraft_type:
|
||||||
|
raise HTTPException(status_code=500, detail="Aircraft has no aircraft_type (data integrity)")
|
||||||
|
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
for k, v in data.items():
|
||||||
|
setattr(a, k, v)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(a)
|
||||||
|
|
||||||
|
# Получаем название оператора
|
||||||
|
operator_name = None
|
||||||
|
if a.operator_id:
|
||||||
|
org = db.query(Organization).filter(Organization.id == a.operator_id).first()
|
||||||
|
if org:
|
||||||
|
operator_name = org.name
|
||||||
|
|
||||||
|
# Создаем объект AircraftOut с дополнительным полем operator_name
|
||||||
|
aircraft_out = AircraftOut.model_validate({
|
||||||
|
"id": a.id,
|
||||||
|
"registration_number": a.registration_number,
|
||||||
|
"aircraft_type": {
|
||||||
|
"id": a.aircraft_type.id,
|
||||||
|
"manufacturer": a.aircraft_type.manufacturer,
|
||||||
|
"model": a.aircraft_type.model,
|
||||||
|
"created_at": a.aircraft_type.created_at,
|
||||||
|
"updated_at": a.aircraft_type.updated_at,
|
||||||
|
},
|
||||||
|
"operator_id": a.operator_id,
|
||||||
|
"operator_name": operator_name,
|
||||||
|
"serial_number": getattr(a, 'serial_number', None),
|
||||||
|
"manufacture_date": getattr(a, 'manufacture_date', None),
|
||||||
|
"first_flight_date": getattr(a, 'first_flight_date', None),
|
||||||
|
"total_time": float(getattr(a, 'total_time', 0)) if getattr(a, 'total_time', None) is not None else None,
|
||||||
|
"total_cycles": getattr(a, 'total_cycles', None),
|
||||||
|
"current_status": getattr(a, 'current_status', 'in_service') or "in_service",
|
||||||
|
"configuration": getattr(a, 'configuration', None),
|
||||||
|
"drawing_numbers": getattr(a, 'drawing_numbers', None),
|
||||||
|
"work_completion_date": getattr(a, 'work_completion_date', None),
|
||||||
|
"created_at": a.created_at,
|
||||||
|
"updated_at": a.updated_at,
|
||||||
|
})
|
||||||
|
return aircraft_out
|
||||||
186
backend/app/api/routes/airworthiness.py
Normal file
186
backend/app/api/routes/airworthiness.py
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
"""
|
||||||
|
API routes для управления лётной годностью согласно требованиям ИКАО Annex 8.
|
||||||
|
|
||||||
|
Соответствует требованиям:
|
||||||
|
- ИКАО Annex 8 (Airworthiness of Aircraft)
|
||||||
|
- EASA Part M (Continuing Airworthiness)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, require_roles
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import AirworthinessCertificate, AircraftHistory, Aircraft
|
||||||
|
from app.schemas.airworthiness import (
|
||||||
|
AirworthinessCertificateCreate,
|
||||||
|
AirworthinessCertificateOut,
|
||||||
|
AirworthinessCertificateUpdate,
|
||||||
|
AircraftHistoryCreate,
|
||||||
|
AircraftHistoryOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["airworthiness"])
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Airworthiness Certificate (ДЛГ) ==========
|
||||||
|
|
||||||
|
@router.get("/airworthiness/certificates", response_model=list[AirworthinessCertificateOut])
|
||||||
|
def list_certificates(
|
||||||
|
aircraft_id: str | None = Query(None, description="Filter by aircraft ID"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Получить список сертификатов лётной годности."""
|
||||||
|
query = db.query(AirworthinessCertificate)
|
||||||
|
|
||||||
|
if aircraft_id:
|
||||||
|
query = query.filter(AirworthinessCertificate.aircraft_id == aircraft_id)
|
||||||
|
|
||||||
|
# Operator-bound visibility
|
||||||
|
if user.role.startswith("operator") and user.organization_id:
|
||||||
|
query = query.join(Aircraft).filter(Aircraft.operator_id == user.organization_id)
|
||||||
|
|
||||||
|
return query.order_by(AirworthinessCertificate.issue_date.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/airworthiness/certificates",
|
||||||
|
response_model=AirworthinessCertificateOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
def create_certificate(
|
||||||
|
payload: AirworthinessCertificateCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Создать новый сертификат лётной годности."""
|
||||||
|
# Проверка существования ВС
|
||||||
|
aircraft = db.query(Aircraft).filter(Aircraft.id == payload.aircraft_id).first()
|
||||||
|
if not aircraft:
|
||||||
|
raise HTTPException(status_code=404, detail="Aircraft not found")
|
||||||
|
|
||||||
|
# Проверка уникальности номера сертификата
|
||||||
|
if db.query(AirworthinessCertificate).filter(
|
||||||
|
AirworthinessCertificate.certificate_number == payload.certificate_number
|
||||||
|
).first():
|
||||||
|
raise HTTPException(status_code=409, detail="Certificate number already exists")
|
||||||
|
|
||||||
|
cert = AirworthinessCertificate(
|
||||||
|
**payload.model_dump(),
|
||||||
|
issued_by_user_id=user.id,
|
||||||
|
)
|
||||||
|
db.add(cert)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cert)
|
||||||
|
return cert
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/airworthiness/certificates/{cert_id}", response_model=AirworthinessCertificateOut)
|
||||||
|
def get_certificate(
|
||||||
|
cert_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Получить сертификат лётной годности по ID."""
|
||||||
|
cert = db.query(AirworthinessCertificate).filter(AirworthinessCertificate.id == cert_id).first()
|
||||||
|
if not cert:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
# Operator-bound visibility
|
||||||
|
if user.role.startswith("operator") and user.organization_id:
|
||||||
|
aircraft = db.query(Aircraft).filter(Aircraft.id == cert.aircraft_id).first()
|
||||||
|
if aircraft and aircraft.operator_id != user.organization_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
return cert
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/airworthiness/certificates/{cert_id}",
|
||||||
|
response_model=AirworthinessCertificateOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
def update_certificate(
|
||||||
|
cert_id: str,
|
||||||
|
payload: AirworthinessCertificateUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Обновить сертификат лётной годности."""
|
||||||
|
cert = db.query(AirworthinessCertificate).filter(AirworthinessCertificate.id == cert_id).first()
|
||||||
|
if not cert:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
for k, v in data.items():
|
||||||
|
setattr(cert, k, v)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cert)
|
||||||
|
return cert
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Aircraft History ==========
|
||||||
|
|
||||||
|
@router.get("/aircraft/{aircraft_id}/history", response_model=list[AircraftHistoryOut])
|
||||||
|
def get_aircraft_history(
|
||||||
|
aircraft_id: str,
|
||||||
|
event_type: str | None = Query(None, description="Filter by event type"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Получить историю событий воздушного судна."""
|
||||||
|
# Проверка доступа к ВС
|
||||||
|
aircraft = db.query(Aircraft).filter(Aircraft.id == aircraft_id).first()
|
||||||
|
if not aircraft:
|
||||||
|
raise HTTPException(status_code=404, detail="Aircraft not found")
|
||||||
|
|
||||||
|
if user.role.startswith("operator") and user.organization_id and aircraft.operator_id != user.organization_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
query = db.query(AircraftHistory).filter(AircraftHistory.aircraft_id == aircraft_id)
|
||||||
|
|
||||||
|
if event_type:
|
||||||
|
query = query.filter(AircraftHistory.event_type == event_type)
|
||||||
|
|
||||||
|
return query.order_by(AircraftHistory.event_date.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/aircraft/{aircraft_id}/history",
|
||||||
|
response_model=AircraftHistoryOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager", "mro_user", "mro_manager"))],
|
||||||
|
)
|
||||||
|
def create_history_entry(
|
||||||
|
aircraft_id: str,
|
||||||
|
payload: AircraftHistoryCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Создать запись в истории ВС."""
|
||||||
|
# Проверка существования ВС
|
||||||
|
aircraft = db.query(Aircraft).filter(Aircraft.id == aircraft_id).first()
|
||||||
|
if not aircraft:
|
||||||
|
raise HTTPException(status_code=404, detail="Aircraft not found")
|
||||||
|
|
||||||
|
# Проверка доступа
|
||||||
|
if user.role.startswith("operator") and user.organization_id and aircraft.operator_id != user.organization_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
# Установка aircraft_id из URL
|
||||||
|
history_data = payload.model_dump()
|
||||||
|
history_data["aircraft_id"] = aircraft_id
|
||||||
|
|
||||||
|
# Автоматическое заполнение организации и пользователя
|
||||||
|
if not history_data.get("performed_by_org_id") and user.organization_id:
|
||||||
|
history_data["performed_by_org_id"] = user.organization_id
|
||||||
|
if not history_data.get("performed_by_user_id"):
|
||||||
|
history_data["performed_by_user_id"] = user.id
|
||||||
|
|
||||||
|
history = AircraftHistory(**history_data)
|
||||||
|
db.add(history)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(history)
|
||||||
|
return history
|
||||||
81
backend/app/api/routes/attachments.py
Normal file
81
backend/app/api/routes/attachments.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import Attachment
|
||||||
|
from app.schemas.attachment import AttachmentOut
|
||||||
|
from app.services.storage import save_upload
|
||||||
|
|
||||||
|
router = APIRouter(tags=["attachments"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/attachments/{owner_kind}/{owner_id}", response_model=AttachmentOut)
|
||||||
|
async def upload_attachment(owner_kind: str, owner_id: str, file: UploadFile = File(...), db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
storage_path, filename = await save_upload(owner_kind, owner_id, file)
|
||||||
|
att = Attachment(
|
||||||
|
owner_kind=owner_kind,
|
||||||
|
owner_id=owner_id,
|
||||||
|
filename=filename,
|
||||||
|
content_type=file.content_type,
|
||||||
|
storage_path=storage_path,
|
||||||
|
uploaded_by_user_id=user.id,
|
||||||
|
)
|
||||||
|
db.add(att)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(att)
|
||||||
|
return att
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/attachments/{attachment_id}", response_model=AttachmentOut)
|
||||||
|
def get_attachment_meta(attachment_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
att = db.query(Attachment).filter(Attachment.id == attachment_id).first()
|
||||||
|
if not att:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
return att
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/attachments/{attachment_id}/download")
|
||||||
|
def download_attachment(attachment_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
att = db.query(Attachment).filter(Attachment.id == attachment_id).first()
|
||||||
|
if not att:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
return FileResponse(path=att.storage_path, filename=att.filename, media_type=att.content_type)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/attachments/{attachment_id}", status_code=204)
|
||||||
|
def delete_attachment(attachment_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
att = db.query(Attachment).filter(Attachment.id == attachment_id).first()
|
||||||
|
if not att:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
# Удаляем файл с диска
|
||||||
|
if os.path.exists(att.storage_path):
|
||||||
|
try:
|
||||||
|
os.remove(att.storage_path)
|
||||||
|
except Exception as e:
|
||||||
|
# Логируем ошибку, но продолжаем удаление записи из БД
|
||||||
|
print(f"Error deleting file {att.storage_path}: {e}")
|
||||||
|
|
||||||
|
# Удаляем запись из БД
|
||||||
|
db.delete(att)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/attachments/{owner_kind}/{owner_id}", response_model=list[AttachmentOut])
|
||||||
|
def list_attachments(
|
||||||
|
owner_kind: str,
|
||||||
|
owner_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
return (
|
||||||
|
db.query(Attachment)
|
||||||
|
.filter(Attachment.owner_kind == owner_kind)
|
||||||
|
.filter(Attachment.owner_id == owner_id)
|
||||||
|
.order_by(Attachment.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
23
backend/app/api/routes/audit.py
Normal file
23
backend/app/api/routes/audit.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
"""
|
||||||
|
Журналирование событий (p.4.1.5 ТЗ).
|
||||||
|
Заглушка: полный audit_log будет реализован при наличии таблицы audit_events.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from typing import List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.core.auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(tags=["audit"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit/events")
|
||||||
|
def list_audit_events(
|
||||||
|
entity_type: str | None = Query(None, description="Фильтр по типу сущности"),
|
||||||
|
entity_id: str | None = Query(None, description="Фильтр по ID сущности"),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Список событий аудита. Заглушка — возвращает пустой список."""
|
||||||
|
# TODO: подключить таблицу audit_events и реальные данные
|
||||||
|
return []
|
||||||
218
backend/app/api/routes/cert_applications.py
Normal file
218
backend/app/api/routes/cert_applications.py
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, require_roles
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.integration.piv import push_event
|
||||||
|
from app.models import CertApplication, ApplicationRemark, CertApplicationStatus
|
||||||
|
from app.schemas.cert_application import CertApplicationCreate, CertApplicationOut, RemarkCreate, RemarkOut
|
||||||
|
from app.services.notifications import notify
|
||||||
|
|
||||||
|
router = APIRouter(tags=["cert_applications"])
|
||||||
|
|
||||||
|
|
||||||
|
def _next_application_number(db: Session) -> str:
|
||||||
|
# Prototype numbering: KLG-YYYYMMDD-NNNN
|
||||||
|
today = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||||
|
prefix = f"KLG-{today}-"
|
||||||
|
existing = db.query(CertApplication).filter(CertApplication.number.like(prefix + "%")).count()
|
||||||
|
return prefix + str(existing + 1).zfill(4)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cert-applications", response_model=list[CertApplicationOut])
|
||||||
|
def list_apps(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
from app.models.organization import Organization
|
||||||
|
|
||||||
|
q = db.query(CertApplication)
|
||||||
|
if user.role.startswith("operator") or user.role.startswith("mro"):
|
||||||
|
if user.organization_id:
|
||||||
|
q = q.filter(CertApplication.applicant_org_id == user.organization_id)
|
||||||
|
else:
|
||||||
|
q = q.filter(False)
|
||||||
|
apps = q.order_by(CertApplication.created_at.desc()).all()
|
||||||
|
|
||||||
|
# Добавляем название организации-заявителя
|
||||||
|
result = []
|
||||||
|
for app in apps:
|
||||||
|
org_name = None
|
||||||
|
if app.applicant_org_id:
|
||||||
|
org = db.query(Organization).filter(Organization.id == app.applicant_org_id).first()
|
||||||
|
if org:
|
||||||
|
org_name = org.name
|
||||||
|
|
||||||
|
# Создаем объект с дополнительным полем applicant_org_name
|
||||||
|
app_dict = {
|
||||||
|
"id": app.id,
|
||||||
|
"number": app.number,
|
||||||
|
"status": app.status.value if hasattr(app.status, 'value') else str(app.status),
|
||||||
|
"applicant_org_id": app.applicant_org_id,
|
||||||
|
"created_by_user_id": app.created_by_user_id,
|
||||||
|
"submitted_at": app.submitted_at,
|
||||||
|
"remarks_deadline_at": app.remarks_deadline_at,
|
||||||
|
"subject": app.subject,
|
||||||
|
"description": app.description,
|
||||||
|
"created_at": app.created_at,
|
||||||
|
"updated_at": app.updated_at,
|
||||||
|
"applicant_org_name": org_name,
|
||||||
|
}
|
||||||
|
result.append(CertApplicationOut.model_validate(app_dict))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/cert-applications",
|
||||||
|
response_model=CertApplicationOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager", "mro_user", "mro_manager"))],
|
||||||
|
)
|
||||||
|
def create_app(payload: CertApplicationCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
if not user.organization_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User has no organization_id")
|
||||||
|
app = CertApplication(
|
||||||
|
applicant_org_id=user.organization_id,
|
||||||
|
created_by_user_id=user.id,
|
||||||
|
number=_next_application_number(db),
|
||||||
|
status=CertApplicationStatus.DRAFT,
|
||||||
|
subject=payload.subject,
|
||||||
|
description=payload.description,
|
||||||
|
)
|
||||||
|
db.add(app)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cert-applications/{app_id}", response_model=CertApplicationOut)
|
||||||
|
def get_app(app_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
app = db.query(CertApplication).filter(CertApplication.id == app_id).first()
|
||||||
|
if not app:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
if (user.role.startswith("operator") or user.role.startswith("mro")) and user.organization_id != app.applicant_org_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/cert-applications/{app_id}/submit",
|
||||||
|
response_model=CertApplicationOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager", "mro_user", "mro_manager"))],
|
||||||
|
)
|
||||||
|
async def submit_app(app_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
app = db.query(CertApplication).filter(CertApplication.id == app_id).first()
|
||||||
|
if not app:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
if user.organization_id != app.applicant_org_id and user.role != "admin":
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
if app.status not in {CertApplicationStatus.DRAFT, CertApplicationStatus.REMARKS}:
|
||||||
|
raise HTTPException(status_code=409, detail="Invalid status")
|
||||||
|
|
||||||
|
app.status = CertApplicationStatus.SUBMITTED
|
||||||
|
app.submitted_at = datetime.now(timezone.utc)
|
||||||
|
app.remarks_deadline_at = None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(app)
|
||||||
|
|
||||||
|
# Notify authority (prototype: notify admin users is out of scope; we log to П-ИВ)
|
||||||
|
await push_event("cert_application_submitted", {"number": app.number, "app_id": app.id})
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/cert-applications/{app_id}/start-review",
|
||||||
|
response_model=CertApplicationOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
def start_review(app_id: str, db: Session = Depends(get_db)):
|
||||||
|
app = db.query(CertApplication).filter(CertApplication.id == app_id).first()
|
||||||
|
if not app:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
if app.status != CertApplicationStatus.SUBMITTED:
|
||||||
|
raise HTTPException(status_code=409, detail="Invalid status")
|
||||||
|
app.status = CertApplicationStatus.UNDER_REVIEW
|
||||||
|
db.commit()
|
||||||
|
db.refresh(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/cert-applications/{app_id}/remarks",
|
||||||
|
response_model=RemarkOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
def add_remark(app_id: str, payload: RemarkCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
app = db.query(CertApplication).filter(CertApplication.id == app_id).first()
|
||||||
|
if not app:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
# Transition to REMARKS and set deadline
|
||||||
|
app.status = CertApplicationStatus.REMARKS
|
||||||
|
app.remarks_deadline_at = datetime.now(timezone.utc) + timedelta(days=settings.remark_deadline_days)
|
||||||
|
|
||||||
|
r = ApplicationRemark(application_id=app_id, author_user_id=user.id, text=payload.text)
|
||||||
|
db.add(r)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(r)
|
||||||
|
|
||||||
|
notify(
|
||||||
|
db,
|
||||||
|
recipient_user_id=app.created_by_user_id,
|
||||||
|
title=f"Заявка {app.number}: выставлены замечания",
|
||||||
|
body=f"Срок устранения: {settings.remark_deadline_days} дней (до {app.remarks_deadline_at.isoformat()}).",
|
||||||
|
)
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cert-applications/{app_id}/remarks", response_model=list[RemarkOut])
|
||||||
|
def list_remarks(app_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
app = db.query(CertApplication).filter(CertApplication.id == app_id).first()
|
||||||
|
if not app:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
if (user.role.startswith("operator") or user.role.startswith("mro")) and user.organization_id != app.applicant_org_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
return (
|
||||||
|
db.query(ApplicationRemark)
|
||||||
|
.filter(ApplicationRemark.application_id == app_id)
|
||||||
|
.order_by(ApplicationRemark.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/cert-applications/{app_id}/approve",
|
||||||
|
response_model=CertApplicationOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
def approve(app_id: str, db: Session = Depends(get_db)):
|
||||||
|
app = db.query(CertApplication).filter(CertApplication.id == app_id).first()
|
||||||
|
if not app:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
if app.status not in {CertApplicationStatus.UNDER_REVIEW, CertApplicationStatus.SUBMITTED}:
|
||||||
|
raise HTTPException(status_code=409, detail="Invalid status")
|
||||||
|
app.status = CertApplicationStatus.APPROVED
|
||||||
|
app.remarks_deadline_at = None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/cert-applications/{app_id}/reject",
|
||||||
|
response_model=CertApplicationOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
def reject(app_id: str, db: Session = Depends(get_db)):
|
||||||
|
app = db.query(CertApplication).filter(CertApplication.id == app_id).first()
|
||||||
|
if not app:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
if app.status not in {CertApplicationStatus.UNDER_REVIEW, CertApplicationStatus.SUBMITTED, CertApplicationStatus.REMARKS}:
|
||||||
|
raise HTTPException(status_code=409, detail="Invalid status")
|
||||||
|
app.status = CertApplicationStatus.REJECTED
|
||||||
|
app.remarks_deadline_at = None
|
||||||
|
db.commit()
|
||||||
|
db.refresh(app)
|
||||||
|
return app
|
||||||
217
backend/app/api/routes/checklist_audits.py
Normal file
217
backend/app/api/routes/checklist_audits.py
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
"""API для проведения аудитов (проверок) по чек-листам."""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, require_roles
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import Audit, AuditResponse, Finding, ChecklistTemplate, ChecklistItem, Aircraft
|
||||||
|
from app.schemas.audit import (
|
||||||
|
AuditCreate, AuditOut, AuditResponseCreate, AuditResponseOut, FindingOut
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["checklist-audits"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audits", response_model=list[AuditOut])
|
||||||
|
def list_audits(
|
||||||
|
aircraft_id: str | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Список аудитов."""
|
||||||
|
q = db.query(Audit)
|
||||||
|
|
||||||
|
if aircraft_id:
|
||||||
|
q = q.filter(Audit.aircraft_id == aircraft_id)
|
||||||
|
if status:
|
||||||
|
q = q.filter(Audit.status == status)
|
||||||
|
|
||||||
|
# Фильтр по организации для операторов
|
||||||
|
if user.role.startswith("operator") and user.organization_id:
|
||||||
|
q = q.join(Aircraft).filter(Aircraft.operator_id == user.organization_id)
|
||||||
|
|
||||||
|
audits = q.order_by(Audit.created_at.desc()).limit(200).all()
|
||||||
|
return [AuditOut.model_validate(a) for a in audits]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/audits",
|
||||||
|
response_model=AuditOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector", "operator_manager"))],
|
||||||
|
)
|
||||||
|
def create_audit(
|
||||||
|
payload: AuditCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Создаёт новый аудит."""
|
||||||
|
template = db.query(ChecklistTemplate).filter(ChecklistTemplate.id == payload.template_id).first()
|
||||||
|
if not template:
|
||||||
|
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||||
|
|
||||||
|
aircraft = db.query(Aircraft).filter(Aircraft.id == payload.aircraft_id).first()
|
||||||
|
if not aircraft:
|
||||||
|
raise HTTPException(status_code=404, detail="ВС не найдено")
|
||||||
|
|
||||||
|
audit = Audit(
|
||||||
|
template_id=payload.template_id,
|
||||||
|
aircraft_id=payload.aircraft_id,
|
||||||
|
planned_at=payload.planned_at,
|
||||||
|
inspector_user_id=user.id,
|
||||||
|
status="draft"
|
||||||
|
)
|
||||||
|
db.add(audit)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(audit)
|
||||||
|
return AuditOut.model_validate(audit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audits/{audit_id}", response_model=AuditOut)
|
||||||
|
def get_audit(
|
||||||
|
audit_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Получает аудит с ответами и находками."""
|
||||||
|
audit = db.query(Audit).filter(Audit.id == audit_id).first()
|
||||||
|
if not audit:
|
||||||
|
raise HTTPException(status_code=404, detail="Аудит не найден")
|
||||||
|
|
||||||
|
return AuditOut.model_validate(audit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/audits/{audit_id}/responses",
|
||||||
|
response_model=AuditResponseOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector", "operator_manager"))],
|
||||||
|
)
|
||||||
|
def submit_response(
|
||||||
|
audit_id: str,
|
||||||
|
payload: AuditResponseCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Отправляет ответ по пункту чек-листа. Автоматически создаёт Finding при non_compliant."""
|
||||||
|
audit = db.query(Audit).filter(Audit.id == audit_id).first()
|
||||||
|
if not audit:
|
||||||
|
raise HTTPException(status_code=404, detail="Аудит не найден")
|
||||||
|
|
||||||
|
if audit.status == "completed":
|
||||||
|
raise HTTPException(status_code=400, detail="Аудит уже завершён")
|
||||||
|
|
||||||
|
item = db.query(ChecklistItem).filter(ChecklistItem.id == payload.item_id).first()
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Пункт чек-листа не найден")
|
||||||
|
|
||||||
|
# Проверяем, нет ли уже ответа
|
||||||
|
existing = db.query(AuditResponse).filter(
|
||||||
|
AuditResponse.audit_id == audit_id,
|
||||||
|
AuditResponse.item_id == payload.item_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.answer = payload.answer
|
||||||
|
existing.comment = payload.comment
|
||||||
|
existing.evidence_attachment_ids = payload.evidence_attachment_ids
|
||||||
|
response = existing
|
||||||
|
else:
|
||||||
|
response = AuditResponse(
|
||||||
|
audit_id=audit_id,
|
||||||
|
item_id=payload.item_id,
|
||||||
|
answer=payload.answer,
|
||||||
|
comment=payload.comment,
|
||||||
|
evidence_attachment_ids=payload.evidence_attachment_ids
|
||||||
|
)
|
||||||
|
db.add(response)
|
||||||
|
|
||||||
|
# Автоматически создаём Finding при non_compliant
|
||||||
|
if payload.answer == "non_compliant":
|
||||||
|
existing_finding = db.query(Finding).filter(
|
||||||
|
Finding.audit_id == audit_id,
|
||||||
|
Finding.item_id == payload.item_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not existing_finding:
|
||||||
|
# Определяем severity и risk_score на основе типа пункта
|
||||||
|
severity = "high"
|
||||||
|
risk_score = 75
|
||||||
|
if "критич" in item.text.lower() or "безопасн" in item.text.lower():
|
||||||
|
severity = "critical"
|
||||||
|
risk_score = 100
|
||||||
|
elif "рекоменд" in item.text.lower():
|
||||||
|
severity = "medium"
|
||||||
|
risk_score = 50
|
||||||
|
|
||||||
|
finding = Finding(
|
||||||
|
audit_id=audit_id,
|
||||||
|
response_id=response.id if hasattr(response, 'id') else None,
|
||||||
|
item_id=payload.item_id,
|
||||||
|
severity=severity,
|
||||||
|
risk_score=risk_score,
|
||||||
|
status="open",
|
||||||
|
description=f"Несоответствие по пункту {item.code}: {item.text}"
|
||||||
|
)
|
||||||
|
db.add(finding)
|
||||||
|
|
||||||
|
# Обновляем статус аудита
|
||||||
|
if audit.status == "draft":
|
||||||
|
audit.status = "in_progress"
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(response)
|
||||||
|
return AuditResponseOut.model_validate(response)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audits/{audit_id}/responses", response_model=list[AuditResponseOut])
|
||||||
|
def list_responses(
|
||||||
|
audit_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Список ответов по аудиту."""
|
||||||
|
audit = db.query(Audit).filter(Audit.id == audit_id).first()
|
||||||
|
if not audit:
|
||||||
|
raise HTTPException(status_code=404, detail="Аудит не найден")
|
||||||
|
|
||||||
|
responses = db.query(AuditResponse).filter(AuditResponse.audit_id == audit_id).all()
|
||||||
|
return [AuditResponseOut.model_validate(r) for r in responses]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audits/{audit_id}/findings", response_model=list[FindingOut])
|
||||||
|
def list_findings(
|
||||||
|
audit_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Список находок (несоответствий) по аудиту."""
|
||||||
|
audit = db.query(Audit).filter(Audit.id == audit_id).first()
|
||||||
|
if not audit:
|
||||||
|
raise HTTPException(status_code=404, detail="Аудит не найден")
|
||||||
|
|
||||||
|
findings = db.query(Finding).filter(Finding.audit_id == audit_id).order_by(Finding.severity.desc()).all()
|
||||||
|
return [FindingOut.model_validate(f) for f in findings]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/audits/{audit_id}/complete",
|
||||||
|
response_model=AuditOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
def complete_audit(
|
||||||
|
audit_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Завершает аудит."""
|
||||||
|
audit = db.query(Audit).filter(Audit.id == audit_id).first()
|
||||||
|
if not audit:
|
||||||
|
raise HTTPException(status_code=404, detail="Аудит не найден")
|
||||||
|
|
||||||
|
audit.status = "completed"
|
||||||
|
audit.completed_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(audit)
|
||||||
|
return AuditOut.model_validate(audit)
|
||||||
245
backend/app/api/routes/checklists.py
Normal file
245
backend/app/api/routes/checklists.py
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
"""API для управления чек-листами."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, File, UploadFile
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, require_roles
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import ChecklistTemplate, ChecklistItem
|
||||||
|
from app.schemas.audit import (
|
||||||
|
ChecklistTemplateCreate, ChecklistTemplateOut,
|
||||||
|
ChecklistItemCreate, ChecklistItemOut
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["checklists"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/checklists/templates", response_model=list[ChecklistTemplateOut])
|
||||||
|
def list_templates(
|
||||||
|
domain: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Список шаблонов чек-листов."""
|
||||||
|
q = db.query(ChecklistTemplate).filter(ChecklistTemplate.is_active == True)
|
||||||
|
if domain:
|
||||||
|
q = q.filter(ChecklistTemplate.domain == domain)
|
||||||
|
templates = q.order_by(ChecklistTemplate.name, ChecklistTemplate.version.desc()).all()
|
||||||
|
result = []
|
||||||
|
for t in templates:
|
||||||
|
items = db.query(ChecklistItem).filter(ChecklistItem.template_id == t.id).order_by(ChecklistItem.sort_order).all()
|
||||||
|
out = ChecklistTemplateOut.model_validate(t)
|
||||||
|
out.items = [ChecklistItemOut.model_validate(i) for i in items]
|
||||||
|
result.append(out)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/checklists/templates",
|
||||||
|
response_model=ChecklistTemplateOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
def create_template(
|
||||||
|
payload: ChecklistTemplateCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Создаёт новый шаблон чек-листа."""
|
||||||
|
template = ChecklistTemplate(
|
||||||
|
name=payload.name,
|
||||||
|
version=payload.version,
|
||||||
|
description=payload.description,
|
||||||
|
domain=payload.domain
|
||||||
|
)
|
||||||
|
db.add(template)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
if payload.items:
|
||||||
|
for item_data in payload.items:
|
||||||
|
item = ChecklistItem(
|
||||||
|
template_id=template.id,
|
||||||
|
code=item_data.code,
|
||||||
|
text=item_data.text,
|
||||||
|
domain=item_data.domain,
|
||||||
|
sort_order=item_data.sort_order
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(template)
|
||||||
|
|
||||||
|
out = ChecklistTemplateOut.model_validate(template)
|
||||||
|
if payload.items:
|
||||||
|
items = db.query(ChecklistItem).filter(ChecklistItem.template_id == template.id).all()
|
||||||
|
out.items = [ChecklistItemOut.model_validate(i) for i in items]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/checklists/generate",
|
||||||
|
response_model=ChecklistTemplateOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
def generate_checklist(
|
||||||
|
source: str, # "fap_m_inspection" | "ata" | "custom"
|
||||||
|
name: str,
|
||||||
|
items: list[ChecklistItemCreate] | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Автоматически генерирует чек-лист из предустановленного шаблона или пользовательских данных."""
|
||||||
|
template = ChecklistTemplate(name=name, version=1, domain=source)
|
||||||
|
db.add(template)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
if source == "fap_m_inspection":
|
||||||
|
# Предустановленный шаблон для проверки по ФАП-М
|
||||||
|
preset_items = [
|
||||||
|
ChecklistItemCreate(code="M.A.301", text="ВС имеет действующий сертификат лётной годности", domain="ФАП-М"),
|
||||||
|
ChecklistItemCreate(code="M.A.302", text="ВС эксплуатируется в соответствии с CAME", domain="ФАП-М"),
|
||||||
|
ChecklistItemCreate(code="M.A.303", text="Выполнены все требования по техническому обслуживанию", domain="ФАП-М"),
|
||||||
|
ChecklistItemCreate(code="M.A.304", text="Все дефекты устранены или имеют действующие разрешения на полёты", domain="ФАП-М"),
|
||||||
|
ChecklistItemCreate(code="M.A.305", text="Документация по ВС актуальна и соответствует требованиям", domain="ФАП-М"),
|
||||||
|
]
|
||||||
|
items = preset_items
|
||||||
|
elif source == "custom" and items:
|
||||||
|
pass # Используем переданные items
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="Неверный source или отсутствуют items")
|
||||||
|
|
||||||
|
for item_data in items:
|
||||||
|
item = ChecklistItem(
|
||||||
|
template_id=template.id,
|
||||||
|
code=item_data.code,
|
||||||
|
text=item_data.text,
|
||||||
|
domain=item_data.domain,
|
||||||
|
sort_order=item_data.sort_order
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(template)
|
||||||
|
|
||||||
|
out = ChecklistTemplateOut.model_validate(template)
|
||||||
|
items_db = db.query(ChecklistItem).filter(ChecklistItem.template_id == template.id).all()
|
||||||
|
out.items = [ChecklistItemOut.model_validate(i) for i in items_db]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/checklists/templates/{template_id}", response_model=ChecklistTemplateOut)
|
||||||
|
def get_template(
|
||||||
|
template_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Получает шаблон с пунктами."""
|
||||||
|
template = db.query(ChecklistTemplate).filter(ChecklistTemplate.id == template_id).first()
|
||||||
|
if not template:
|
||||||
|
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||||
|
|
||||||
|
items = db.query(ChecklistItem).filter(ChecklistItem.template_id == template_id).order_by(ChecklistItem.sort_order).all()
|
||||||
|
out = ChecklistTemplateOut.model_validate(template)
|
||||||
|
out.items = [ChecklistItemOut.model_validate(i) for i in items]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/checklists/generate-from-csv",
|
||||||
|
response_model=ChecklistTemplateOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
async def generate_checklist_from_csv(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
name: str | None = None,
|
||||||
|
domain: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Генерирует чек-лист из CSV файла (например, из REFLY_Jira_Backlog_Subtasks_Dependencies.csv).
|
||||||
|
|
||||||
|
Ожидаемые колонки в CSV:
|
||||||
|
- Issue Id (или Issue Id) - код пункта
|
||||||
|
- Summary (или Description) - текст требования
|
||||||
|
- Domain (опционально) - домен
|
||||||
|
- Story Points (опционально) - для sort_order
|
||||||
|
"""
|
||||||
|
content = await file.read()
|
||||||
|
|
||||||
|
# Парсим CSV
|
||||||
|
try:
|
||||||
|
text = content.decode('utf-8-sig')
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
text = content.decode('cp1251')
|
||||||
|
|
||||||
|
reader = csv.DictReader(io.StringIO(text))
|
||||||
|
fieldnames = reader.fieldnames or []
|
||||||
|
rows = list(reader)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=400, detail="CSV файл пуст или неверного формата")
|
||||||
|
|
||||||
|
# Определяем колонки (поддерживаем разные варианты названий)
|
||||||
|
code_col = None
|
||||||
|
text_col = None
|
||||||
|
domain_col = None
|
||||||
|
order_col = None
|
||||||
|
|
||||||
|
for col in fieldnames:
|
||||||
|
col_lower = col.lower()
|
||||||
|
if 'issue' in col_lower and 'id' in col_lower and not code_col:
|
||||||
|
code_col = col
|
||||||
|
elif ('summary' in col_lower or 'description' in col_lower) and not text_col:
|
||||||
|
text_col = col
|
||||||
|
elif 'domain' in col_lower and not domain_col:
|
||||||
|
domain_col = col
|
||||||
|
elif ('story' in col_lower and 'point' in col_lower) or 'order' in col_lower:
|
||||||
|
order_col = col
|
||||||
|
|
||||||
|
if not code_col or not text_col:
|
||||||
|
raise HTTPException(status_code=400, detail="Не найдены обязательные колонки: Issue Id и Summary/Description")
|
||||||
|
|
||||||
|
# Создаём шаблон
|
||||||
|
template_name = name or file.filename.replace('.csv', '').replace('_', ' ').title()
|
||||||
|
template = ChecklistTemplate(
|
||||||
|
name=template_name,
|
||||||
|
version=1,
|
||||||
|
domain=domain or "REFLY_CSV"
|
||||||
|
)
|
||||||
|
db.add(template)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Создаём пункты
|
||||||
|
items_created = []
|
||||||
|
for idx, row in enumerate(rows):
|
||||||
|
code = str(row.get(code_col, f"ITEM_{idx+1}")).strip()
|
||||||
|
text = str(row.get(text_col, "")).strip()
|
||||||
|
|
||||||
|
if not code or not text:
|
||||||
|
continue # Пропускаем пустые строки
|
||||||
|
|
||||||
|
item_domain = domain or (row.get(domain_col, "") if domain_col else None) or None
|
||||||
|
sort_order = int(row.get(order_col, idx + 1)) if order_col and row.get(order_col) else idx + 1
|
||||||
|
|
||||||
|
item = ChecklistItem(
|
||||||
|
template_id=template.id,
|
||||||
|
code=code,
|
||||||
|
text=text,
|
||||||
|
domain=item_domain,
|
||||||
|
sort_order=sort_order
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
items_created.append(item)
|
||||||
|
|
||||||
|
if not items_created:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=400, detail="Не удалось создать ни одного пункта из CSV")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(template)
|
||||||
|
|
||||||
|
out = ChecklistTemplateOut.model_validate(template)
|
||||||
|
items_db = db.query(ChecklistItem).filter(ChecklistItem.template_id == template.id).order_by(ChecklistItem.sort_order).all()
|
||||||
|
out.items = [ChecklistItemOut.model_validate(i) for i in items_db]
|
||||||
|
return out
|
||||||
8
backend/app/api/routes/health.py
Normal file
8
backend/app/api/routes/health.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
173
backend/app/api/routes/inbox.py
Normal file
173
backend/app/api/routes/inbox.py
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
"""
|
||||||
|
COD-004: Inbox API — интеграция из Express inbox-server в FastAPI.
|
||||||
|
Использует тот же формат данных (SQLite + файлы) для совместимости.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/inbox", tags=["inbox"])
|
||||||
|
|
||||||
|
DATA_DIR = Path(settings.INBOX_DATA_DIR).resolve()
|
||||||
|
INBOX_DIR = DATA_DIR / "ai-inbox"
|
||||||
|
DB_PATH = DATA_DIR / "db" / "inbox.db"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_dirs():
|
||||||
|
INBOX_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_db():
|
||||||
|
_ensure_dirs()
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS file_registry (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
original_name TEXT NOT NULL,
|
||||||
|
stored_path TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
status TEXT DEFAULT 'pending'
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_extraction_run (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
file_id TEXT NOT NULL,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
completed_at TEXT,
|
||||||
|
status TEXT DEFAULT 'running',
|
||||||
|
error TEXT,
|
||||||
|
FOREIGN KEY (file_id) REFERENCES file_registry(id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_extraction_field (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
run_id TEXT NOT NULL,
|
||||||
|
field_code TEXT NOT NULL,
|
||||||
|
value TEXT,
|
||||||
|
confidence REAL,
|
||||||
|
provenance TEXT,
|
||||||
|
FOREIGN KEY (run_id) REFERENCES ai_extraction_run(id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tmc_request_draft (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
file_id TEXT NOT NULL,
|
||||||
|
extraction_run_id TEXT,
|
||||||
|
status TEXT DEFAULT 'draft',
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT,
|
||||||
|
FOREIGN KEY (file_id) REFERENCES file_registry(id),
|
||||||
|
FOREIGN KEY (extraction_run_id) REFERENCES ai_extraction_run(id)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files")
|
||||||
|
def list_files(user=Depends(get_current_user)):
|
||||||
|
"""Список файлов в inbox"""
|
||||||
|
conn = _get_db()
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, original_name, stored_path, mime, size, sha256, created_at, status FROM file_registry ORDER BY created_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
def upload_file(file: UploadFile = File(...), user=Depends(get_current_user)):
|
||||||
|
"""Загрузка файла в inbox"""
|
||||||
|
allowed = [
|
||||||
|
"application/pdf",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/msword",
|
||||||
|
]
|
||||||
|
if file.content_type not in allowed:
|
||||||
|
raise HTTPException(400, "Разрешены только PDF и DOCX")
|
||||||
|
max_size = settings.INBOX_UPLOAD_MAX_MB * 1024 * 1024
|
||||||
|
|
||||||
|
_ensure_dirs()
|
||||||
|
file_id = str(uuid.uuid4())
|
||||||
|
safe_name = "".join(c if c.isalnum() or c in "._-" else "_" for c in (file.filename or "file"))
|
||||||
|
stored_name = f"{file_id}_{safe_name}"
|
||||||
|
stored_path = INBOX_DIR / stored_name
|
||||||
|
|
||||||
|
content = file.file.read()
|
||||||
|
if len(content) > max_size:
|
||||||
|
raise HTTPException(400, f"Файл превышает {settings.INBOX_UPLOAD_MAX_MB} МБ")
|
||||||
|
sha256 = hashlib.sha256(content).hexdigest()
|
||||||
|
stored_path.write_bytes(content)
|
||||||
|
|
||||||
|
db_path_rel = f"ai-inbox/{stored_name}"
|
||||||
|
created_at = __import__("datetime").datetime.utcnow().isoformat() + "Z"
|
||||||
|
|
||||||
|
conn = _get_db()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO file_registry (id, original_name, stored_path, mime, size, sha256, created_at, status) VALUES (?,?,?,?,?,?,?,?)",
|
||||||
|
(file_id, file.filename or "file", db_path_rel, file.content_type or "application/octet-stream", len(content), sha256, created_at, "pending"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": file_id,
|
||||||
|
"originalName": file.filename or "file",
|
||||||
|
"storedPath": db_path_rel,
|
||||||
|
"mime": file.content_type or "application/octet-stream",
|
||||||
|
"size": len(content),
|
||||||
|
"sha256": sha256,
|
||||||
|
"createdAt": created_at,
|
||||||
|
"status": "pending",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/{file_id}/download")
|
||||||
|
def download_file(file_id: str, user=Depends(get_current_user)):
|
||||||
|
"""Скачать файл"""
|
||||||
|
conn = _get_db()
|
||||||
|
try:
|
||||||
|
row = conn.execute("SELECT * FROM file_registry WHERE id = ?", (file_id,)).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "File not found")
|
||||||
|
path = DATA_DIR / row["stored_path"] if not str(row["stored_path"]).startswith("/") else Path(row["stored_path"])
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(404, "File not found on disk")
|
||||||
|
return FileResponse(path, filename=row["original_name"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/files/{file_id}")
|
||||||
|
def delete_file(file_id: str, user=Depends(get_current_user)):
|
||||||
|
"""Удалить файл"""
|
||||||
|
conn = _get_db()
|
||||||
|
try:
|
||||||
|
row = conn.execute("SELECT * FROM file_registry WHERE id = ?", (file_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "File not found")
|
||||||
|
path = DATA_DIR / row["stored_path"] if not str(row["stored_path"]).startswith("/") else Path(row["stored_path"])
|
||||||
|
if path.exists():
|
||||||
|
path.unlink()
|
||||||
|
conn.execute("DELETE FROM file_registry WHERE id = ?", (file_id,))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return {"success": True}
|
||||||
268
backend/app/api/routes/ingest.py
Normal file
268
backend/app/api/routes/ingest.py
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, require_roles
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import IngestJobLog, MaintenanceTask, DefectReport, LimitedLifeComponent, LandingGearComponent, ChecklistItem, ChecklistTemplate, Aircraft
|
||||||
|
|
||||||
|
router = APIRouter(tags=["ingest"])
|
||||||
|
|
||||||
|
|
||||||
|
class IngestLogCreate(BaseModel):
|
||||||
|
source_system: str
|
||||||
|
job_name: str
|
||||||
|
status: str
|
||||||
|
details: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ParseResultItem(BaseModel):
|
||||||
|
name: str
|
||||||
|
headers: list[str]
|
||||||
|
rows: list[dict[str, Any]]
|
||||||
|
row_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class ParseArchiveResponse(BaseModel):
|
||||||
|
items: list[ParseResultItem]
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ImportTableRequest(BaseModel):
|
||||||
|
target: str # maintenance_tasks | defect_reports | limited_life_components | landing_gear_components | checklist_items
|
||||||
|
aircraft_id: str | None = None
|
||||||
|
template_id: str | None = None
|
||||||
|
column_mapping: dict[str, str] # {"field_name": "header_name"}
|
||||||
|
rows: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/ingest/logs",
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
def create_ingest_log(payload: IngestLogCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
log = IngestJobLog(
|
||||||
|
source_system=payload.source_system,
|
||||||
|
job_name=payload.job_name,
|
||||||
|
status=payload.status,
|
||||||
|
details=payload.details,
|
||||||
|
started_at=datetime.now(timezone.utc),
|
||||||
|
finished_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(log)
|
||||||
|
return log
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/ingest/logs",
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
def list_ingest_logs(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
return db.query(IngestJobLog).order_by(IngestJobLog.created_at.desc()).limit(200).all()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_csv(content: bytes) -> tuple[list[str], list[dict[str, Any]]]:
|
||||||
|
"""Парсит CSV файл, возвращает заголовки и строки."""
|
||||||
|
try:
|
||||||
|
text = content.decode('utf-8-sig')
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
text = content.decode('cp1251')
|
||||||
|
|
||||||
|
reader = csv.DictReader(io.StringIO(text))
|
||||||
|
headers = reader.fieldnames or []
|
||||||
|
rows = list(reader)
|
||||||
|
return headers, rows
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_xlsx(content: bytes) -> list[tuple[str, list[str], list[dict[str, Any]]]]:
|
||||||
|
"""Парсит XLSX файл, возвращает список (sheet_name, headers, rows)."""
|
||||||
|
wb = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
|
||||||
|
result = []
|
||||||
|
for sheet_name in wb.sheetnames:
|
||||||
|
ws = wb[sheet_name]
|
||||||
|
if ws.max_row == 0:
|
||||||
|
continue
|
||||||
|
headers = [str(cell.value or '') for cell in ws[1]]
|
||||||
|
rows = []
|
||||||
|
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||||
|
if not any(row):
|
||||||
|
continue
|
||||||
|
rows.append({headers[i]: str(val) if val is not None else '' for i, val in enumerate(row)})
|
||||||
|
result.append((sheet_name, headers, rows))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/ingest/parse-archive",
|
||||||
|
response_model=ParseArchiveResponse,
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector", "operator_manager"))],
|
||||||
|
)
|
||||||
|
async def parse_archive(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Парсит ZIP/CSV/XLSX архив и возвращает структурированные данные для табличного просмотра."""
|
||||||
|
content = await file.read()
|
||||||
|
items = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
if file.filename.endswith('.zip'):
|
||||||
|
with zipfile.ZipFile(io.BytesIO(content)) as z:
|
||||||
|
for name in z.namelist():
|
||||||
|
if name.endswith('/'):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
file_content = z.read(name)
|
||||||
|
if name.endswith('.csv'):
|
||||||
|
headers, rows = _parse_csv(file_content)
|
||||||
|
items.append(ParseResultItem(
|
||||||
|
name=name,
|
||||||
|
headers=headers,
|
||||||
|
rows=rows[:500], # Ограничение для preview
|
||||||
|
row_count=len(rows)
|
||||||
|
))
|
||||||
|
elif name.endswith(('.xlsx', '.xls')):
|
||||||
|
for sheet_name, headers, rows in _parse_xlsx(file_content):
|
||||||
|
items.append(ParseResultItem(
|
||||||
|
name=f"{name}/{sheet_name}",
|
||||||
|
headers=headers,
|
||||||
|
rows=rows[:500],
|
||||||
|
row_count=len(rows)
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Ошибка обработки {name}: {str(e)}")
|
||||||
|
elif file.filename.endswith('.csv'):
|
||||||
|
headers, rows = _parse_csv(content)
|
||||||
|
items.append(ParseResultItem(
|
||||||
|
name=file.filename,
|
||||||
|
headers=headers,
|
||||||
|
rows=rows[:500],
|
||||||
|
row_count=len(rows)
|
||||||
|
))
|
||||||
|
elif file.filename.endswith(('.xlsx', '.xls')):
|
||||||
|
for sheet_name, headers, rows in _parse_xlsx(content):
|
||||||
|
items.append(ParseResultItem(
|
||||||
|
name=f"{file.filename}/{sheet_name}",
|
||||||
|
headers=headers,
|
||||||
|
rows=rows[:500],
|
||||||
|
row_count=len(rows)
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="Неподдерживаемый формат файла")
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Ошибка парсинга: {str(e)}")
|
||||||
|
|
||||||
|
return ParseArchiveResponse(items=items, errors=errors)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/ingest/import-table",
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector", "operator_manager"))],
|
||||||
|
)
|
||||||
|
def import_table(
|
||||||
|
payload: ImportTableRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Импортирует данные из таблицы в указанную целевую таблицу."""
|
||||||
|
imported = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
if payload.target == "maintenance_tasks":
|
||||||
|
if not payload.aircraft_id:
|
||||||
|
raise HTTPException(status_code=400, detail="aircraft_id обязателен для maintenance_tasks")
|
||||||
|
aircraft = db.query(Aircraft).filter(Aircraft.id == payload.aircraft_id).first()
|
||||||
|
if not aircraft:
|
||||||
|
raise HTTPException(status_code=404, detail="ВС не найдено")
|
||||||
|
|
||||||
|
for row in payload.rows:
|
||||||
|
try:
|
||||||
|
task = MaintenanceTask(aircraft_id=payload.aircraft_id)
|
||||||
|
for field, header in payload.column_mapping.items():
|
||||||
|
if hasattr(task, field) and header in row:
|
||||||
|
setattr(task, field, row[header] or None)
|
||||||
|
db.add(task)
|
||||||
|
imported += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Строка {imported + len(errors) + 1}: {str(e)}")
|
||||||
|
|
||||||
|
elif payload.target == "defect_reports":
|
||||||
|
if not payload.aircraft_id:
|
||||||
|
raise HTTPException(status_code=400, detail="aircraft_id обязателен для defect_reports")
|
||||||
|
for row in payload.rows:
|
||||||
|
try:
|
||||||
|
report = DefectReport(aircraft_id=payload.aircraft_id)
|
||||||
|
for field, header in payload.column_mapping.items():
|
||||||
|
if hasattr(report, field) and header in row:
|
||||||
|
setattr(report, field, row[header] or None)
|
||||||
|
db.add(report)
|
||||||
|
imported += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Строка {imported + len(errors) + 1}: {str(e)}")
|
||||||
|
|
||||||
|
elif payload.target == "limited_life_components":
|
||||||
|
if not payload.aircraft_id:
|
||||||
|
raise HTTPException(status_code=400, detail="aircraft_id обязателен для limited_life_components")
|
||||||
|
for row in payload.rows:
|
||||||
|
try:
|
||||||
|
comp = LimitedLifeComponent(aircraft_id=payload.aircraft_id)
|
||||||
|
for field, header in payload.column_mapping.items():
|
||||||
|
if hasattr(comp, field) and header in row:
|
||||||
|
setattr(comp, field, row[header] or None)
|
||||||
|
db.add(comp)
|
||||||
|
imported += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Строка {imported + len(errors) + 1}: {str(e)}")
|
||||||
|
|
||||||
|
elif payload.target == "landing_gear_components":
|
||||||
|
if not payload.aircraft_id:
|
||||||
|
raise HTTPException(status_code=400, detail="aircraft_id обязателен для landing_gear_components")
|
||||||
|
for row in payload.rows:
|
||||||
|
try:
|
||||||
|
comp = LandingGearComponent(aircraft_id=payload.aircraft_id)
|
||||||
|
for field, header in payload.column_mapping.items():
|
||||||
|
if hasattr(comp, field) and header in row:
|
||||||
|
setattr(comp, field, row[header] or None)
|
||||||
|
db.add(comp)
|
||||||
|
imported += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Строка {imported + len(errors) + 1}: {str(e)}")
|
||||||
|
|
||||||
|
elif payload.target == "checklist_items":
|
||||||
|
if not payload.template_id:
|
||||||
|
raise HTTPException(status_code=400, detail="template_id обязателен для checklist_items")
|
||||||
|
template = db.query(ChecklistTemplate).filter(ChecklistTemplate.id == payload.template_id).first()
|
||||||
|
if not template:
|
||||||
|
raise HTTPException(status_code=404, detail="Шаблон не найден")
|
||||||
|
|
||||||
|
for row in payload.rows:
|
||||||
|
try:
|
||||||
|
item = ChecklistItem(template_id=payload.template_id)
|
||||||
|
for field, header in payload.column_mapping.items():
|
||||||
|
if hasattr(item, field) and header in row:
|
||||||
|
setattr(item, field, row[header] or None)
|
||||||
|
db.add(item)
|
||||||
|
imported += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Строка {imported + len(errors) + 1}: {str(e)}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Неподдерживаемый target: {payload.target}")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"imported": imported,
|
||||||
|
"errors": errors,
|
||||||
|
"status": "partial" if errors else "success"
|
||||||
|
}
|
||||||
346
backend/app/api/routes/legal.py
Normal file
346
backend/app/api/routes/legal.py
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
"""
|
||||||
|
API маршруты для системы юридических документов:
|
||||||
|
- юрисдикции, документы, перекрёстные ссылки, правовые комментарии, судебная практика
|
||||||
|
- запуск мультиагентного ИИ-анализа и подготовки документов по нормам
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, require_roles
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import Jurisdiction, LegalDocument, CrossReference, LegalComment, JudicialPractice
|
||||||
|
from app.schemas.legal import (
|
||||||
|
JurisdictionCreate,
|
||||||
|
JurisdictionUpdate,
|
||||||
|
JurisdictionOut,
|
||||||
|
LegalDocumentCreate,
|
||||||
|
LegalDocumentUpdate,
|
||||||
|
LegalDocumentOut,
|
||||||
|
CrossReferenceCreate,
|
||||||
|
CrossReferenceOut,
|
||||||
|
LegalCommentCreate,
|
||||||
|
LegalCommentUpdate,
|
||||||
|
LegalCommentOut,
|
||||||
|
JudicialPracticeCreate,
|
||||||
|
JudicialPracticeUpdate,
|
||||||
|
JudicialPracticeOut,
|
||||||
|
AnalysisRequest,
|
||||||
|
AnalysisResponse,
|
||||||
|
)
|
||||||
|
from app.services.legal_agents import LegalAnalysisOrchestrator
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/legal", tags=["legal"])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Jurisdictions ---
|
||||||
|
|
||||||
|
@router.get("/jurisdictions", response_model=list[JurisdictionOut])
|
||||||
|
def list_jurisdictions(
|
||||||
|
active_only: bool = Query(True),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
q = db.query(Jurisdiction)
|
||||||
|
if active_only:
|
||||||
|
q = q.filter(Jurisdiction.is_active.is_(True))
|
||||||
|
return q.order_by(Jurisdiction.code).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/jurisdictions", response_model=JurisdictionOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_jurisdiction(
|
||||||
|
payload: JurisdictionCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(require_roles("admin")),
|
||||||
|
):
|
||||||
|
j = Jurisdiction(**payload.model_dump())
|
||||||
|
db.add(j)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(j)
|
||||||
|
return j
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jurisdictions/{jid}", response_model=JurisdictionOut)
|
||||||
|
def get_jurisdiction(jid: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
j = db.get(Jurisdiction, jid)
|
||||||
|
if not j:
|
||||||
|
raise HTTPException(status_code=404, detail="Jurisdiction not found")
|
||||||
|
return j
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/jurisdictions/{jid}", response_model=JurisdictionOut)
|
||||||
|
def update_jurisdiction(
|
||||||
|
jid: str,
|
||||||
|
payload: JurisdictionUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(require_roles("admin")),
|
||||||
|
):
|
||||||
|
j = db.get(Jurisdiction, jid)
|
||||||
|
if not j:
|
||||||
|
raise HTTPException(status_code=404, detail="Jurisdiction not found")
|
||||||
|
for k, v in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(j, k, v)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(j)
|
||||||
|
return j
|
||||||
|
|
||||||
|
|
||||||
|
# --- Legal Documents ---
|
||||||
|
|
||||||
|
@router.get("/documents", response_model=list[LegalDocumentOut])
|
||||||
|
def list_legal_documents(
|
||||||
|
jurisdiction_id: str | None = Query(None),
|
||||||
|
document_type: str | None = Query(None),
|
||||||
|
limit: int = Query(100, le=500),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
q = db.query(LegalDocument)
|
||||||
|
if jurisdiction_id:
|
||||||
|
q = q.filter(LegalDocument.jurisdiction_id == jurisdiction_id)
|
||||||
|
if document_type:
|
||||||
|
q = q.filter(LegalDocument.document_type == document_type)
|
||||||
|
return q.order_by(LegalDocument.created_at.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/documents", response_model=LegalDocumentOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_legal_document(
|
||||||
|
payload: LegalDocumentCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(require_roles("admin", "authority_inspector")),
|
||||||
|
):
|
||||||
|
d = LegalDocument(**payload.model_dump())
|
||||||
|
db.add(d)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(d)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/documents/{doc_id}", response_model=LegalDocumentOut)
|
||||||
|
def get_legal_document(doc_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
d = db.get(LegalDocument, doc_id)
|
||||||
|
if not d:
|
||||||
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/documents/{doc_id}", response_model=LegalDocumentOut)
|
||||||
|
def update_legal_document(
|
||||||
|
doc_id: str,
|
||||||
|
payload: LegalDocumentUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(require_roles("admin", "authority_inspector")),
|
||||||
|
):
|
||||||
|
d = db.get(LegalDocument, doc_id)
|
||||||
|
if not d:
|
||||||
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
for k, v in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(d, k, v)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(d)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/documents/{doc_id}/cross-references", response_model=list[CrossReferenceOut])
|
||||||
|
def list_document_cross_references(
|
||||||
|
doc_id: str,
|
||||||
|
direction: str = Query("outgoing", description="outgoing|incoming"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
q = db.query(CrossReference)
|
||||||
|
if direction == "incoming":
|
||||||
|
q = q.filter(CrossReference.target_document_id == doc_id)
|
||||||
|
else:
|
||||||
|
q = q.filter(CrossReference.source_document_id == doc_id)
|
||||||
|
return q.all()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Cross References (ручное добавление) ---
|
||||||
|
|
||||||
|
@router.post("/cross-references", response_model=CrossReferenceOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_cross_reference(
|
||||||
|
payload: CrossReferenceCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(require_roles("admin", "authority_inspector")),
|
||||||
|
):
|
||||||
|
ref = CrossReference(**payload.model_dump())
|
||||||
|
db.add(ref)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ref)
|
||||||
|
return ref
|
||||||
|
|
||||||
|
|
||||||
|
# --- Legal Comments ---
|
||||||
|
|
||||||
|
@router.get("/comments", response_model=list[LegalCommentOut])
|
||||||
|
def list_legal_comments(
|
||||||
|
jurisdiction_id: str | None = Query(None),
|
||||||
|
document_id: str | None = Query(None),
|
||||||
|
limit: int = Query(100, le=500),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
q = db.query(LegalComment)
|
||||||
|
if jurisdiction_id:
|
||||||
|
q = q.filter(LegalComment.jurisdiction_id == jurisdiction_id)
|
||||||
|
if document_id:
|
||||||
|
q = q.filter(LegalComment.document_id == document_id)
|
||||||
|
return q.order_by(LegalComment.created_at.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/comments", response_model=LegalCommentOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_legal_comment(
|
||||||
|
payload: LegalCommentCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(require_roles("admin", "authority_inspector")),
|
||||||
|
):
|
||||||
|
c = LegalComment(**payload.model_dump())
|
||||||
|
db.add(c)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(c)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/comments/{cid}", response_model=LegalCommentOut)
|
||||||
|
def update_legal_comment(
|
||||||
|
cid: str,
|
||||||
|
payload: LegalCommentUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(require_roles("admin", "authority_inspector")),
|
||||||
|
):
|
||||||
|
c = db.get(LegalComment, cid)
|
||||||
|
if not c:
|
||||||
|
raise HTTPException(status_code=404, detail="Comment not found")
|
||||||
|
for k, v in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(c, k, v)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(c)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
# --- Judicial Practice ---
|
||||||
|
|
||||||
|
@router.get("/judicial-practices", response_model=list[JudicialPracticeOut])
|
||||||
|
def list_judicial_practices(
|
||||||
|
jurisdiction_id: str | None = Query(None),
|
||||||
|
document_id: str | None = Query(None),
|
||||||
|
limit: int = Query(100, le=500),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
q = db.query(JudicialPractice)
|
||||||
|
if jurisdiction_id:
|
||||||
|
q = q.filter(JudicialPractice.jurisdiction_id == jurisdiction_id)
|
||||||
|
if document_id:
|
||||||
|
q = q.filter(JudicialPractice.document_id == document_id)
|
||||||
|
# nulls_last портировано под SQLite < 3.30: (col IS NULL) ASC — не-NULL первыми
|
||||||
|
return q.order_by(
|
||||||
|
JudicialPractice.decision_date.is_(None),
|
||||||
|
JudicialPractice.decision_date.desc(),
|
||||||
|
JudicialPractice.created_at.desc(),
|
||||||
|
).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/judicial-practices", response_model=JudicialPracticeOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_judicial_practice(
|
||||||
|
payload: JudicialPracticeCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(require_roles("admin", "authority_inspector")),
|
||||||
|
):
|
||||||
|
p = JudicialPractice(**payload.model_dump())
|
||||||
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(p)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/judicial-practices/{pid}", response_model=JudicialPracticeOut)
|
||||||
|
def update_judicial_practice(
|
||||||
|
pid: str,
|
||||||
|
payload: JudicialPracticeUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(require_roles("admin", "authority_inspector")),
|
||||||
|
):
|
||||||
|
p = db.get(JudicialPractice, pid)
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(status_code=404, detail="Judicial practice not found")
|
||||||
|
for k, v in payload.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(p, k, v)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(p)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# --- ИИ-анализ (мультиагентный) ---
|
||||||
|
|
||||||
|
@router.post("/analyze", response_model=AnalysisResponse)
|
||||||
|
def analyze_document(
|
||||||
|
payload: AnalysisRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(require_roles("admin", "authority_inspector")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Запуск мультиагентного анализа: классификация, соответствие нормам, перекрёстные ссылки,
|
||||||
|
подбор правовых комментариев и судебной практики, рекомендации по оформлению.
|
||||||
|
"""
|
||||||
|
orch = LegalAnalysisOrchestrator(db=db)
|
||||||
|
out = orch.run(
|
||||||
|
document_id=payload.document_id,
|
||||||
|
jurisdiction_id=payload.jurisdiction_id,
|
||||||
|
title=payload.title,
|
||||||
|
content=payload.content,
|
||||||
|
skip_agents=payload.skip_agents,
|
||||||
|
save_cross_references=payload.save_cross_references,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Опционально: обновить документ в БД, если document_id передан
|
||||||
|
if payload.document_id and out.get("document_type"):
|
||||||
|
d = db.get(LegalDocument, payload.document_id)
|
||||||
|
if d:
|
||||||
|
d.document_type = out["document_type"]
|
||||||
|
d.analysis_json = out.get("analysis_json")
|
||||||
|
d.compliance_notes = out.get("compliance_notes")
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return AnalysisResponse(
|
||||||
|
document_type=out["document_type"],
|
||||||
|
analysis_json=out.get("analysis_json"),
|
||||||
|
compliance_notes=out.get("compliance_notes"),
|
||||||
|
results=out.get("results", {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/documents/{doc_id}/analyze", response_model=AnalysisResponse)
|
||||||
|
def analyze_existing_document(
|
||||||
|
doc_id: str,
|
||||||
|
skip_agents: list[str] | None = Query(None),
|
||||||
|
save_cross_references: bool = Query(True),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(require_roles("admin", "authority_inspector")),
|
||||||
|
):
|
||||||
|
"""Запуск ИИ-анализа для уже существующего документа по id."""
|
||||||
|
d = db.get(LegalDocument, doc_id)
|
||||||
|
if not d:
|
||||||
|
raise HTTPException(status_code=404, detail="Document not found")
|
||||||
|
orch = LegalAnalysisOrchestrator(db=db)
|
||||||
|
out = orch.run(
|
||||||
|
document_id=doc_id,
|
||||||
|
jurisdiction_id=d.jurisdiction_id,
|
||||||
|
title=d.title,
|
||||||
|
content=d.content,
|
||||||
|
existing_document_type=d.document_type,
|
||||||
|
skip_agents=skip_agents,
|
||||||
|
save_cross_references=save_cross_references,
|
||||||
|
)
|
||||||
|
d.document_type = out["document_type"]
|
||||||
|
d.analysis_json = out.get("analysis_json")
|
||||||
|
d.compliance_notes = out.get("compliance_notes")
|
||||||
|
db.commit()
|
||||||
|
db.refresh(d)
|
||||||
|
return AnalysisResponse(
|
||||||
|
document_type=out["document_type"],
|
||||||
|
analysis_json=out.get("analysis_json"),
|
||||||
|
compliance_notes=out.get("compliance_notes"),
|
||||||
|
results=out.get("results", {}),
|
||||||
|
)
|
||||||
162
backend/app/api/routes/modifications.py
Normal file
162
backend/app/api/routes/modifications.py
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
"""
|
||||||
|
API routes для управления модификациями воздушных судов.
|
||||||
|
|
||||||
|
Соответствует требованиям ИКАО Annex 8: отслеживание обязательных модификаций
|
||||||
|
(AD - Airworthiness Directives, SB - Service Bulletins, STC - Supplemental Type Certificates).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, require_roles
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import AircraftModification, Aircraft
|
||||||
|
from app.schemas.modifications import (
|
||||||
|
AircraftModificationCreate,
|
||||||
|
AircraftModificationOut,
|
||||||
|
AircraftModificationUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["modifications"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/aircraft/{aircraft_id}/modifications", response_model=list[AircraftModificationOut])
|
||||||
|
def list_modifications(
|
||||||
|
aircraft_id: str,
|
||||||
|
modification_type: str | None = Query(None, description="Filter by modification type (AD, SB, STC)"),
|
||||||
|
compliance_status: str | None = Query(None, description="Filter by compliance status"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Получить список модификаций для воздушного судна."""
|
||||||
|
# Проверка доступа к ВС
|
||||||
|
aircraft = db.query(Aircraft).filter(Aircraft.id == aircraft_id).first()
|
||||||
|
if not aircraft:
|
||||||
|
raise HTTPException(status_code=404, detail="Aircraft not found")
|
||||||
|
|
||||||
|
if user.role.startswith("operator") and user.organization_id and aircraft.operator_id != user.organization_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
query = db.query(AircraftModification).filter(AircraftModification.aircraft_id == aircraft_id)
|
||||||
|
|
||||||
|
if modification_type:
|
||||||
|
query = query.filter(AircraftModification.modification_type == modification_type)
|
||||||
|
|
||||||
|
if compliance_status:
|
||||||
|
query = query.filter(AircraftModification.compliance_status == compliance_status)
|
||||||
|
|
||||||
|
return query.order_by(AircraftModification.compliance_date.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/aircraft/{aircraft_id}/modifications",
|
||||||
|
response_model=AircraftModificationOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
def create_modification(
|
||||||
|
aircraft_id: str,
|
||||||
|
payload: AircraftModificationCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Создать новую модификацию для воздушного судна."""
|
||||||
|
# Проверка существования ВС
|
||||||
|
aircraft = db.query(Aircraft).filter(Aircraft.id == aircraft_id).first()
|
||||||
|
if not aircraft:
|
||||||
|
raise HTTPException(status_code=404, detail="Aircraft not found")
|
||||||
|
|
||||||
|
# Проверка доступа
|
||||||
|
if user.role.startswith("operator") and user.organization_id and aircraft.operator_id != user.organization_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
# Установка aircraft_id из URL
|
||||||
|
mod_data = payload.model_dump()
|
||||||
|
mod_data["aircraft_id"] = aircraft_id
|
||||||
|
|
||||||
|
# Автоматическое заполнение организации и пользователя (если не указаны)
|
||||||
|
if not mod_data.get("performed_by_org_id") and user.organization_id:
|
||||||
|
mod_data["performed_by_org_id"] = user.organization_id
|
||||||
|
if not mod_data.get("performed_by_user_id"):
|
||||||
|
mod_data["performed_by_user_id"] = user.id
|
||||||
|
|
||||||
|
modification = AircraftModification(**mod_data)
|
||||||
|
db.add(modification)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(modification)
|
||||||
|
return modification
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/modifications/{mod_id}", response_model=AircraftModificationOut)
|
||||||
|
def get_modification(
|
||||||
|
mod_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Получить модификацию по ID."""
|
||||||
|
modification = db.query(AircraftModification).filter(AircraftModification.id == mod_id).first()
|
||||||
|
if not modification:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
# Проверка доступа к связанному ВС
|
||||||
|
aircraft = db.query(Aircraft).filter(Aircraft.id == modification.aircraft_id).first()
|
||||||
|
if user.role.startswith("operator") and user.organization_id and aircraft.operator_id != user.organization_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
return modification
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/modifications/{mod_id}",
|
||||||
|
response_model=AircraftModificationOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "operator_user", "operator_manager", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
def update_modification(
|
||||||
|
mod_id: str,
|
||||||
|
payload: AircraftModificationUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Обновить модификацию."""
|
||||||
|
modification = db.query(AircraftModification).filter(AircraftModification.id == mod_id).first()
|
||||||
|
if not modification:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
# Проверка доступа к связанному ВС
|
||||||
|
aircraft = db.query(Aircraft).filter(Aircraft.id == modification.aircraft_id).first()
|
||||||
|
if user.role.startswith("operator") and user.organization_id and aircraft.operator_id != user.organization_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
for k, v in data.items():
|
||||||
|
setattr(modification, k, v)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(modification)
|
||||||
|
return modification
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/modifications", response_model=list[AircraftModificationOut])
|
||||||
|
def list_all_modifications(
|
||||||
|
compliance_required: bool | None = Query(None, description="Filter by compliance required"),
|
||||||
|
compliance_status: str | None = Query(None, description="Filter by compliance status"),
|
||||||
|
modification_type: str | None = Query(None, description="Filter by modification type"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Получить список всех модификаций (с фильтрацией)."""
|
||||||
|
query = db.query(AircraftModification)
|
||||||
|
|
||||||
|
# Operator-bound visibility
|
||||||
|
if user.role.startswith("operator") and user.organization_id:
|
||||||
|
query = query.join(Aircraft).filter(Aircraft.operator_id == user.organization_id)
|
||||||
|
|
||||||
|
if compliance_required is not None:
|
||||||
|
query = query.filter(AircraftModification.compliance_required == compliance_required)
|
||||||
|
|
||||||
|
if compliance_status:
|
||||||
|
query = query.filter(AircraftModification.compliance_status == compliance_status)
|
||||||
|
|
||||||
|
if modification_type:
|
||||||
|
query = query.filter(AircraftModification.modification_type == modification_type)
|
||||||
|
|
||||||
|
return query.order_by(AircraftModification.compliance_date.desc()).all()
|
||||||
38
backend/app/api/routes/notifications.py
Normal file
38
backend/app/api/routes/notifications.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import Notification
|
||||||
|
from app.schemas.notification import NotificationOut
|
||||||
|
|
||||||
|
router = APIRouter(tags=["notifications"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notifications", response_model=list[NotificationOut])
|
||||||
|
def list_my_notifications(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
return (
|
||||||
|
db.query(Notification)
|
||||||
|
.filter(Notification.recipient_user_id == user.id)
|
||||||
|
.order_by(Notification.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
@router.post("/{notification_id}/read", response_model=NotificationOut)
|
||||||
|
def mark_read(
|
||||||
|
notification_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
n = db.query(Notification).filter(Notification.id == notification_id).first()
|
||||||
|
|
||||||
|
if not n:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
if n.recipient_user_id != user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
|
||||||
|
n.is_read = True
|
||||||
|
db.commit()
|
||||||
|
db.refresh(n)
|
||||||
|
return n
|
||||||
135
backend/app/api/routes/organizations.py
Normal file
135
backend/app/api/routes/organizations.py
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
"""
|
||||||
|
API routes для управления организациями.
|
||||||
|
|
||||||
|
Соответствует требованиям ТЗ: управление организациями (операторы, MRO, органы власти).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, require_roles
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import Organization, User, Aircraft, CertApplication
|
||||||
|
from app.schemas.organization import OrganizationCreate, OrganizationOut, OrganizationUpdate
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["organizations"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/organizations", response_model=list[OrganizationOut])
|
||||||
|
def list_organizations(
|
||||||
|
q: str | None = Query(None, description="Search by organization name"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Получить список организаций.
|
||||||
|
|
||||||
|
Authority видит все; остальные видят только свою организацию.
|
||||||
|
Поддерживает поиск по названию организации.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = db.query(Organization)
|
||||||
|
if user.role not in {"admin", "authority_inspector"} and user.organization_id:
|
||||||
|
query = query.filter(Organization.id == user.organization_id)
|
||||||
|
if q:
|
||||||
|
query = query.filter(Organization.name.ilike(f"%{q}%"))
|
||||||
|
orgs = query.order_by(Organization.name).all()
|
||||||
|
result = []
|
||||||
|
for org in orgs:
|
||||||
|
try:
|
||||||
|
result.append(OrganizationOut.model_validate(org))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при сериализации организации {org.id}: {str(e)}", exc_info=True)
|
||||||
|
# Пропускаем проблемную организацию, но продолжаем обработку остальных
|
||||||
|
continue
|
||||||
|
logger.info(f"Успешно возвращено {len(result)} организаций из {len(orgs)}")
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
logger.error(f"Ошибка при получении списка организаций: {str(e)}", exc_info=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Ошибка при получении списка организаций: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/organizations",
|
||||||
|
response_model=OrganizationOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
def create_organization(payload: OrganizationCreate, db: Session = Depends(get_db)):
|
||||||
|
"""Создать новую организацию."""
|
||||||
|
org = Organization(**payload.model_dump())
|
||||||
|
db.add(org)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(org)
|
||||||
|
return OrganizationOut.model_validate(org)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/organizations/{org_id}", response_model=OrganizationOut)
|
||||||
|
def get_organization(org_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
"""Получить детали организации по ID."""
|
||||||
|
org = db.query(Organization).filter(Organization.id == org_id).first()
|
||||||
|
if not org:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
if user.role not in {"admin", "authority_inspector"} and user.organization_id != org_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden")
|
||||||
|
return OrganizationOut.model_validate(org)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/organizations/{org_id}",
|
||||||
|
response_model=OrganizationOut,
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
def update_organization(org_id: str, payload: OrganizationUpdate, db: Session = Depends(get_db)):
|
||||||
|
"""Обновить организацию."""
|
||||||
|
org = db.query(Organization).filter(Organization.id == org_id).first()
|
||||||
|
if not org:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
if not data:
|
||||||
|
return OrganizationOut.model_validate(org)
|
||||||
|
|
||||||
|
for k, v in data.items():
|
||||||
|
setattr(org, k, v)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="Conflict (duplicate fields)")
|
||||||
|
db.refresh(org)
|
||||||
|
return OrganizationOut.model_validate(org)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/organizations/{org_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
def delete_organization(org_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Удалить организацию.
|
||||||
|
|
||||||
|
Нельзя удалить, если есть связанные сущности (пользователи, ВС, заявки).
|
||||||
|
"""
|
||||||
|
org = db.query(Organization).filter(Organization.id == org_id).first()
|
||||||
|
if not org:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
# Проверка связанных сущностей
|
||||||
|
if db.query(User).filter(User.organization_id == org_id).count() > 0:
|
||||||
|
raise HTTPException(status_code=409, detail="Organization has users")
|
||||||
|
if db.query(Aircraft).filter(Aircraft.operator_id == org_id).count() > 0:
|
||||||
|
raise HTTPException(status_code=409, detail="Organization has aircraft")
|
||||||
|
if db.query(CertApplication).filter(CertApplication.applicant_org_id == org_id).count() > 0:
|
||||||
|
raise HTTPException(status_code=409, detail="Organization has applications")
|
||||||
|
|
||||||
|
db.delete(org)
|
||||||
|
db.commit()
|
||||||
|
return None
|
||||||
71
backend/app/api/routes/risk_alerts.py
Normal file
71
backend/app/api/routes/risk_alerts.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
"""API для предупреждений о рисках."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, require_roles
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models import RiskAlert
|
||||||
|
from app.schemas.risk_alert import RiskAlertOut
|
||||||
|
from app.services.risk_scanner import scan_risks
|
||||||
|
|
||||||
|
router = APIRouter(tags=["risk-alerts"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/risk-alerts", response_model=list[RiskAlertOut])
|
||||||
|
def list_risk_alerts(
|
||||||
|
aircraft_id: str | None = Query(None),
|
||||||
|
severity: str | None = Query(None),
|
||||||
|
resolved: bool | None = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Список предупреждений о рисках."""
|
||||||
|
q = db.query(RiskAlert)
|
||||||
|
|
||||||
|
if aircraft_id:
|
||||||
|
q = q.filter(RiskAlert.aircraft_id == aircraft_id)
|
||||||
|
if severity:
|
||||||
|
q = q.filter(RiskAlert.severity == severity)
|
||||||
|
if resolved is not None:
|
||||||
|
q = q.filter(RiskAlert.is_resolved == resolved)
|
||||||
|
|
||||||
|
# Фильтр по организации для операторов
|
||||||
|
if user.role.startswith("operator") and user.organization_id:
|
||||||
|
from app.models import Aircraft
|
||||||
|
q = q.join(Aircraft).filter(Aircraft.operator_id == user.organization_id)
|
||||||
|
|
||||||
|
alerts = q.order_by(RiskAlert.due_at.asc(), RiskAlert.severity.desc()).limit(500).all()
|
||||||
|
return [RiskAlertOut.model_validate(a) for a in alerts]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/risk-alerts/scan",
|
||||||
|
dependencies=[Depends(require_roles("admin", "authority_inspector"))],
|
||||||
|
)
|
||||||
|
def trigger_risk_scan(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||||
|
"""Запускает сканирование рисков вручную."""
|
||||||
|
created = scan_risks(db)
|
||||||
|
return {"created": created, "message": f"Создано {created} предупреждений"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/risk-alerts/{alert_id}/resolve")
|
||||||
|
def resolve_alert(
|
||||||
|
alert_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Отмечает предупреждение как решённое."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
alert = db.query(RiskAlert).filter(RiskAlert.id == alert_id).first()
|
||||||
|
if not alert:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=404, detail="Предупреждение не найдено")
|
||||||
|
|
||||||
|
alert.is_resolved = True
|
||||||
|
alert.resolved_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(alert)
|
||||||
|
return RiskAlertOut.model_validate(alert)
|
||||||
39
backend/app/api/routes/tasks.py
Normal file
39
backend/app/api/routes/tasks.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.core.auth import get_current_user
|
||||||
|
from app.models.cert_application import CertApplication
|
||||||
|
from app.schemas.tasks import TaskOut
|
||||||
|
|
||||||
|
router = APIRouter(tags=["tasks"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks", response_model=List[TaskOut])
|
||||||
|
def list_tasks(
|
||||||
|
state: str = Query(default="open"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
q = db.query(CertApplication)
|
||||||
|
|
||||||
|
if state == "open":
|
||||||
|
q = q.filter(CertApplication.status.in_(["submitted", "under_review", "remarks"]))
|
||||||
|
|
||||||
|
apps = q.order_by(CertApplication.updated_at.desc()).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
TaskOut(
|
||||||
|
entity_type="cert_application",
|
||||||
|
entity_id=a.id,
|
||||||
|
title=f"Заявка №{a.number}",
|
||||||
|
status=a.status,
|
||||||
|
due_at=a.remarks_deadline_at,
|
||||||
|
priority="high" if a.remarks_deadline_at and a.remarks_deadline_at <= datetime.utcnow() else "normal",
|
||||||
|
updated_at=a.updated_at,
|
||||||
|
)
|
||||||
|
for a in apps
|
||||||
|
]
|
||||||
107
backend/app/api/routes/users.py
Normal file
107
backend/app/api/routes/users.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
"""
|
||||||
|
API endpoints для управления пользователями.
|
||||||
|
|
||||||
|
В production пользователи управляются через АСУ ТК-ИБ, здесь только чтение.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.organization import Organization
|
||||||
|
from app.schemas.common import _coerce_datetime
|
||||||
|
|
||||||
|
router = APIRouter(tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
external_subject: str
|
||||||
|
display_name: str
|
||||||
|
email: str | None
|
||||||
|
role: str
|
||||||
|
organization_id: str | None
|
||||||
|
organization_name: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
@field_validator("created_at", "updated_at", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_dt(cls, v):
|
||||||
|
return _coerce_datetime(v)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users", response_model=list[UserOut])
|
||||||
|
def list_users(
|
||||||
|
organization_id: str | None = None,
|
||||||
|
role: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Получить список пользователей."""
|
||||||
|
query = db.query(User)
|
||||||
|
|
||||||
|
if organization_id:
|
||||||
|
query = query.filter(User.organization_id == organization_id)
|
||||||
|
|
||||||
|
if role:
|
||||||
|
query = query.filter(User.role == role)
|
||||||
|
|
||||||
|
users = query.order_by(User.display_name).all()
|
||||||
|
|
||||||
|
# Добавляем название организации
|
||||||
|
result = []
|
||||||
|
for u in users:
|
||||||
|
org_name = None
|
||||||
|
if u.organization_id:
|
||||||
|
org = db.query(Organization).filter(Organization.id == u.organization_id).first()
|
||||||
|
if org:
|
||||||
|
org_name = org.name
|
||||||
|
|
||||||
|
result.append(UserOut(
|
||||||
|
id=u.id,
|
||||||
|
external_subject=u.external_subject,
|
||||||
|
display_name=u.display_name,
|
||||||
|
email=u.email,
|
||||||
|
role=u.role,
|
||||||
|
organization_id=u.organization_id,
|
||||||
|
organization_name=org_name,
|
||||||
|
created_at=u.created_at,
|
||||||
|
updated_at=u.updated_at,
|
||||||
|
))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}", response_model=UserOut)
|
||||||
|
def get_user(
|
||||||
|
user_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user=Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Получить информацию о пользователе."""
|
||||||
|
u = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
org_name = None
|
||||||
|
if u.organization_id:
|
||||||
|
org = db.query(Organization).filter(Organization.id == u.organization_id).first()
|
||||||
|
if org:
|
||||||
|
org_name = org.name
|
||||||
|
|
||||||
|
return UserOut(
|
||||||
|
id=u.id,
|
||||||
|
external_subject=u.external_subject,
|
||||||
|
display_name=u.display_name,
|
||||||
|
email=u.email,
|
||||||
|
role=u.role,
|
||||||
|
organization_id=u.organization_id,
|
||||||
|
organization_name=org_name,
|
||||||
|
created_at=u.created_at,
|
||||||
|
updated_at=u.updated_at,
|
||||||
|
)
|
||||||
48
backend/app/api/streaming.py
Normal file
48
backend/app/api/streaming.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
Streaming API endpoints
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from app.streaming.redpanda import publish_event
|
||||||
|
from app.streaming.risingwave import query_materialized_view
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class EventRequest(BaseModel):
|
||||||
|
"""Запрос на публикацию события"""
|
||||||
|
topic: str
|
||||||
|
event: dict
|
||||||
|
key: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/events")
|
||||||
|
async def publish_event_endpoint(request: EventRequest):
|
||||||
|
"""Публикация события в Redpanda"""
|
||||||
|
try:
|
||||||
|
await publish_event(request.topic, request.event, request.key)
|
||||||
|
return {"status": "published", "topic": request.topic}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/views/{view_name}")
|
||||||
|
async def get_materialized_view(view_name: str, filters: Optional[dict] = None):
|
||||||
|
"""Получение данных из materialized view"""
|
||||||
|
try:
|
||||||
|
data = await query_materialized_view(view_name, filters or {})
|
||||||
|
return {"view": view_name, "data": data}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def streaming_health():
|
||||||
|
"""Проверка здоровья streaming компонентов"""
|
||||||
|
return {
|
||||||
|
"redpanda": "connected",
|
||||||
|
"risingwave": "connected",
|
||||||
|
"flink": "connected",
|
||||||
|
}
|
||||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
42
backend/app/core/config.py
Normal file
42
backend/app/core/config.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Конфигурация приложения
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Настройки приложения"""
|
||||||
|
|
||||||
|
# API
|
||||||
|
API_V1_PREFIX: str = "/api/v1"
|
||||||
|
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8000"]
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL: str = "postgresql+asyncpg://klg:klg@localhost:5432/klg"
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL: str = "redis://localhost:6379"
|
||||||
|
|
||||||
|
# Redpanda / RisingWave — ARC-003: отключены по умолчанию для MVP (optional)
|
||||||
|
ENABLE_RISINGWAVE: bool = False
|
||||||
|
ENABLE_REDPANDA: bool = False
|
||||||
|
REDPANDA_BROKERS: str = "localhost:19092" # используется только при ENABLE_REDPANDA=true
|
||||||
|
REDPANDA_CLIENT_ID: str = "klg-backend"
|
||||||
|
RISINGWAVE_URL: str = "postgresql://root:risingwave@localhost:4566/dev" # при ENABLE_RISINGWAVE=true
|
||||||
|
|
||||||
|
# Inbox (COD-004)
|
||||||
|
INBOX_DATA_DIR: str = "./data"
|
||||||
|
INBOX_UPLOAD_MAX_MB: int = 50
|
||||||
|
|
||||||
|
@property
|
||||||
|
def database_url(self) -> str:
|
||||||
|
return self.DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://") if "asyncpg" in self.DATABASE_URL else self.DATABASE_URL
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
case_sensitive = True
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
34
backend/app/core/streaming.py
Normal file
34
backend/app/core/streaming.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
Инициализация streaming компонентов.
|
||||||
|
ARC-003: Redpanda/RisingWave отключены по умолчанию — no-op при ENABLE_*=false.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def init_streaming():
|
||||||
|
"""Инициализация streaming компонентов (no-op при отключённых сервисах)"""
|
||||||
|
if settings.ENABLE_REDPANDA:
|
||||||
|
try:
|
||||||
|
from app.streaming.redpanda import init_redpanda
|
||||||
|
await init_redpanda()
|
||||||
|
logger.info("Redpanda initialized")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to initialize Redpanda: %s", e)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
logger.info("Redpanda disabled (ENABLE_REDPANDA=false)")
|
||||||
|
|
||||||
|
if settings.ENABLE_RISINGWAVE:
|
||||||
|
try:
|
||||||
|
from app.streaming.risingwave import init_risingwave
|
||||||
|
await init_risingwave()
|
||||||
|
logger.info("RisingWave initialized")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to initialize RisingWave: %s", e)
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
logger.info("RisingWave disabled (ENABLE_RISINGWAVE=false)")
|
||||||
0
backend/app/db/__init__.py
Normal file
0
backend/app/db/__init__.py
Normal file
5
backend/app/db/base.py
Normal file
5
backend/app/db/base.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
13
backend/app/db/init_db.py
Normal file
13
backend/app/db/init_db.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from app.db.session import engine
|
||||||
|
from app.db.base import Base
|
||||||
|
|
||||||
|
# Import models so SQLAlchemy registers them
|
||||||
|
from app import models # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_db()
|
||||||
162
backend/app/db/seed_aircraft.sql
Normal file
162
backend/app/db/seed_aircraft.sql
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
-- Скрипт для заполнения воздушных судов для всех операторов
|
||||||
|
|
||||||
|
-- Добавляем воздушные суда для операторов, у которых их еще нет
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
-- ID операторов
|
||||||
|
smartavia_id TEXT;
|
||||||
|
azimuth_id TEXT;
|
||||||
|
yakutia_id TEXT;
|
||||||
|
|
||||||
|
-- ID типов ВС (используем популярные типы)
|
||||||
|
boeing737_id TEXT;
|
||||||
|
airbus320_id TEXT;
|
||||||
|
sukhoi_superjet_id TEXT;
|
||||||
|
boeing777_id TEXT;
|
||||||
|
airbus330_id TEXT;
|
||||||
|
atr72_id TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- Получаем ID операторов
|
||||||
|
SELECT id INTO smartavia_id FROM organizations WHERE name = 'Smartavia' AND kind = 'operator' LIMIT 1;
|
||||||
|
SELECT id INTO azimuth_id FROM organizations WHERE name = 'Азимут' AND kind = 'operator' LIMIT 1;
|
||||||
|
SELECT id INTO yakutia_id FROM organizations WHERE name = 'Якутия' AND kind = 'operator' LIMIT 1;
|
||||||
|
|
||||||
|
-- Получаем ID типов ВС
|
||||||
|
SELECT id INTO boeing737_id FROM aircraft_types WHERE manufacturer = 'Boeing' AND model = '737-800' LIMIT 1;
|
||||||
|
SELECT id INTO airbus320_id FROM aircraft_types WHERE manufacturer = 'Airbus' AND model = 'A320' LIMIT 1;
|
||||||
|
SELECT id INTO sukhoi_superjet_id FROM aircraft_types WHERE manufacturer = 'Sukhoi' AND model = 'Superjet 100' LIMIT 1;
|
||||||
|
SELECT id INTO boeing777_id FROM aircraft_types WHERE manufacturer = 'Boeing' AND model = '777-300ER' LIMIT 1;
|
||||||
|
SELECT id INTO airbus330_id FROM aircraft_types WHERE manufacturer = 'Airbus' AND model = 'A330-300' LIMIT 1;
|
||||||
|
SELECT id INTO atr72_id FROM aircraft_types WHERE manufacturer = 'ATR' AND model = '72-600' LIMIT 1;
|
||||||
|
|
||||||
|
-- Если типов ВС нет, используем первый доступный тип
|
||||||
|
IF boeing737_id IS NULL THEN
|
||||||
|
SELECT id INTO boeing737_id FROM aircraft_types LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
IF airbus320_id IS NULL THEN
|
||||||
|
SELECT id INTO airbus320_id FROM aircraft_types LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
IF sukhoi_superjet_id IS NULL THEN
|
||||||
|
SELECT id INTO sukhoi_superjet_id FROM aircraft_types LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Smartavia - добавляем Boeing 737
|
||||||
|
IF smartavia_id IS NOT NULL AND boeing737_id IS NOT NULL THEN
|
||||||
|
INSERT INTO aircraft (id, registration_number, aircraft_type_id, operator_id, serial_number, current_status, total_time, total_cycles, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'RA-89060', boeing737_id, smartavia_id, 'LN-12345', 'in_service', 15000.5, 8500, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-89061', boeing737_id, smartavia_id, 'LN-12346', 'in_service', 12000.0, 7200, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-89062', boeing737_id, smartavia_id, 'LN-12347', 'maintenance', 18000.2, 10200, NOW(), NOW())
|
||||||
|
ON CONFLICT (registration_number) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Азимут - добавляем Sukhoi Superjet 100
|
||||||
|
IF azimuth_id IS NOT NULL AND sukhoi_superjet_id IS NOT NULL THEN
|
||||||
|
INSERT INTO aircraft (id, registration_number, aircraft_type_id, operator_id, serial_number, current_status, total_time, total_cycles, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'RA-89070', sukhoi_superjet_id, azimuth_id, '95001', 'in_service', 8000.5, 4500, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-89071', sukhoi_superjet_id, azimuth_id, '95002', 'in_service', 7500.0, 4200, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-89072', sukhoi_superjet_id, azimuth_id, '95003', 'in_service', 9200.3, 5100, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-89073', sukhoi_superjet_id, azimuth_id, '95004', 'in_service', 6800.8, 3800, NOW(), NOW())
|
||||||
|
ON CONFLICT (registration_number) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Якутия - добавляем ATR 72 и Boeing 737
|
||||||
|
IF yakutia_id IS NOT NULL THEN
|
||||||
|
IF atr72_id IS NOT NULL THEN
|
||||||
|
INSERT INTO aircraft (id, registration_number, aircraft_type_id, operator_id, serial_number, current_status, total_time, total_cycles, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'RA-89090', atr72_id, yakutia_id, 'ATR-001', 'in_service', 12000.5, 15000, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-89091', atr72_id, yakutia_id, 'ATR-002', 'in_service', 11000.0, 13800, NOW(), NOW())
|
||||||
|
ON CONFLICT (registration_number) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF boeing737_id IS NOT NULL THEN
|
||||||
|
INSERT INTO aircraft (id, registration_number, aircraft_type_id, operator_id, serial_number, current_status, total_time, total_cycles, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'RA-89092', boeing737_id, yakutia_id, 'LN-89001', 'in_service', 20000.0, 11500, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-89093', boeing737_id, yakutia_id, 'LN-89002', 'in_service', 18500.5, 10800, NOW(), NOW())
|
||||||
|
ON CONFLICT (registration_number) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Также добавляем дополнительные ВС для операторов, у которых их мало
|
||||||
|
-- S7 Airlines - добавляем еще несколько
|
||||||
|
IF EXISTS (SELECT 1 FROM organizations WHERE name = 'S7 Airlines' AND kind = 'operator') THEN
|
||||||
|
SELECT id INTO smartavia_id FROM organizations WHERE name = 'S7 Airlines' AND kind = 'operator' LIMIT 1;
|
||||||
|
IF boeing737_id IS NOT NULL THEN
|
||||||
|
INSERT INTO aircraft (id, registration_number, aircraft_type_id, operator_id, serial_number, current_status, total_time, total_cycles, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'RA-73004', boeing737_id, smartavia_id, 'LN-73004', 'in_service', 16000.0, 9000, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-73005', boeing737_id, smartavia_id, 'LN-73005', 'in_service', 14000.5, 8200, NOW(), NOW())
|
||||||
|
ON CONFLICT (registration_number) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Аэрофлот - добавляем еще несколько
|
||||||
|
IF EXISTS (SELECT 1 FROM organizations WHERE name = 'Аэрофлот - Российские авиалинии' AND kind = 'operator') THEN
|
||||||
|
SELECT id INTO smartavia_id FROM organizations WHERE name = 'Аэрофлот - Российские авиалинии' AND kind = 'operator' LIMIT 1;
|
||||||
|
IF airbus330_id IS NOT NULL THEN
|
||||||
|
INSERT INTO aircraft (id, registration_number, aircraft_type_id, operator_id, serial_number, current_status, total_time, total_cycles, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'RA-89004', airbus330_id, smartavia_id, 'MSN-89004', 'in_service', 25000.0, 8500, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-89005', airbus330_id, smartavia_id, 'MSN-89005', 'in_service', 23000.5, 7800, NOW(), NOW())
|
||||||
|
ON CONFLICT (registration_number) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF boeing777_id IS NOT NULL THEN
|
||||||
|
INSERT INTO aircraft (id, registration_number, aircraft_type_id, operator_id, serial_number, current_status, total_time, total_cycles, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'RA-89006', boeing777_id, smartavia_id, 'LN-89006', 'in_service', 30000.0, 6500, NOW(), NOW())
|
||||||
|
ON CONFLICT (registration_number) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Победа - добавляем еще несколько
|
||||||
|
IF EXISTS (SELECT 1 FROM organizations WHERE name = 'Победа' AND kind = 'operator') THEN
|
||||||
|
SELECT id INTO smartavia_id FROM organizations WHERE name = 'Победа' AND kind = 'operator' LIMIT 1;
|
||||||
|
IF boeing737_id IS NOT NULL THEN
|
||||||
|
INSERT INTO aircraft (id, registration_number, aircraft_type_id, operator_id, serial_number, current_status, total_time, total_cycles, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'RA-89082', boeing737_id, smartavia_id, 'LN-89082', 'in_service', 13000.0, 7500, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-89083', boeing737_id, smartavia_id, 'LN-89083', 'in_service', 11000.5, 6800, NOW(), NOW())
|
||||||
|
ON CONFLICT (registration_number) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Россия - добавляем еще несколько
|
||||||
|
IF EXISTS (SELECT 1 FROM organizations WHERE name = 'Россия' AND kind = 'operator') THEN
|
||||||
|
SELECT id INTO smartavia_id FROM organizations WHERE name = 'Россия' AND kind = 'operator' LIMIT 1;
|
||||||
|
IF boeing737_id IS NOT NULL THEN
|
||||||
|
INSERT INTO aircraft (id, registration_number, aircraft_type_id, operator_id, serial_number, current_status, total_time, total_cycles, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'RA-73703', boeing737_id, smartavia_id, 'LN-73703', 'in_service', 17000.0, 9500, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-73704', boeing737_id, smartavia_id, 'LN-73704', 'in_service', 15000.5, 8800, NOW(), NOW())
|
||||||
|
ON CONFLICT (registration_number) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Уральские авиалинии - добавляем еще несколько
|
||||||
|
IF EXISTS (SELECT 1 FROM organizations WHERE name = 'Уральские авиалинии' AND kind = 'operator') THEN
|
||||||
|
SELECT id INTO smartavia_id FROM organizations WHERE name = 'Уральские авиалинии' AND kind = 'operator' LIMIT 1;
|
||||||
|
IF airbus320_id IS NOT NULL THEN
|
||||||
|
INSERT INTO aircraft (id, registration_number, aircraft_type_id, operator_id, serial_number, current_status, total_time, total_cycles, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'RA-89052', airbus320_id, smartavia_id, 'MSN-89052', 'in_service', 19000.0, 10500, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-89053', airbus320_id, smartavia_id, 'MSN-89053', 'in_service', 17500.5, 9800, NOW(), NOW())
|
||||||
|
ON CONFLICT (registration_number) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Проверка результата
|
||||||
|
SELECT
|
||||||
|
o.name as operator_name,
|
||||||
|
COUNT(a.id) as aircraft_count
|
||||||
|
FROM organizations o
|
||||||
|
LEFT JOIN aircraft a ON a.operator_id = o.id
|
||||||
|
WHERE o.kind = 'operator'
|
||||||
|
GROUP BY o.id, o.name
|
||||||
|
ORDER BY o.name;
|
||||||
111
backend/app/db/seed_aircraft_demo.py
Normal file
111
backend/app/db/seed_aircraft_demo.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"""
|
||||||
|
Скрипт для заполнения воздушных судов (самолёты и вертолёты).
|
||||||
|
Работает с SQLite и PostgreSQL. Требует seed_organizations и seed_aircraft_types.
|
||||||
|
Данные из seed_aircraft.sql и seed_helicopters.sql.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.aircraft import Aircraft, AircraftType
|
||||||
|
from app.models.organization import Organization
|
||||||
|
|
||||||
|
# (registration_number, manufacturer, model, operator_name, serial_number, status, total_time, total_cycles)
|
||||||
|
_AIRCRAFT = [
|
||||||
|
# seed_aircraft.sql: Smartavia, Boeing 737-800
|
||||||
|
("RA-89060", "Boeing", "737-800", "Smartavia", "LN-12345", "in_service", 15000.5, 8500),
|
||||||
|
("RA-89061", "Boeing", "737-800", "Smartavia", "LN-12346", "in_service", 12000.0, 7200),
|
||||||
|
("RA-89062", "Boeing", "737-800", "Smartavia", "LN-12347", "maintenance", 18000.2, 10200),
|
||||||
|
# Азимут, Sukhoi Superjet 100
|
||||||
|
("RA-89070", "Sukhoi", "Superjet 100", "Азимут", "95001", "in_service", 8000.5, 4500),
|
||||||
|
("RA-89071", "Sukhoi", "Superjet 100", "Азимут", "95002", "in_service", 7500.0, 4200),
|
||||||
|
("RA-89072", "Sukhoi", "Superjet 100", "Азимут", "95003", "in_service", 9200.3, 5100),
|
||||||
|
("RA-89073", "Sukhoi", "Superjet 100", "Азимут", "95004", "in_service", 6800.8, 3800),
|
||||||
|
# Якутия: ATR 72-600, Boeing 737-800
|
||||||
|
("RA-89090", "ATR", "72-600", "Якутия", "ATR-001", "in_service", 12000.5, 15000),
|
||||||
|
("RA-89091", "ATR", "72-600", "Якутия", "ATR-002", "in_service", 11000.0, 13800),
|
||||||
|
("RA-89092", "Boeing", "737-800", "Якутия", "LN-89001", "in_service", 20000.0, 11500),
|
||||||
|
("RA-89093", "Boeing", "737-800", "Якутия", "LN-89002", "in_service", 18500.5, 10800),
|
||||||
|
# S7, Boeing 737-800
|
||||||
|
("RA-73004", "Boeing", "737-800", "S7 Airlines", "LN-73004", "in_service", 16000.0, 9000),
|
||||||
|
("RA-73005", "Boeing", "737-800", "S7 Airlines", "LN-73005", "in_service", 14000.5, 8200),
|
||||||
|
# Аэрофлот: A330-300, 777-300ER
|
||||||
|
("RA-89004", "Airbus", "A330-300", "Аэрофлот - Российские авиалинии", "MSN-89004", "in_service", 25000.0, 8500),
|
||||||
|
("RA-89005", "Airbus", "A330-300", "Аэрофлот - Российские авиалинии", "MSN-89005", "in_service", 23000.5, 7800),
|
||||||
|
("RA-89006", "Boeing", "777-300ER", "Аэрофлот - Российские авиалинии", "LN-89006", "in_service", 30000.0, 6500),
|
||||||
|
# Победа, Россия, Уральские
|
||||||
|
("RA-89082", "Boeing", "737-800", "Победа", "LN-89082", "in_service", 13000.0, 7500),
|
||||||
|
("RA-89083", "Boeing", "737-800", "Победа", "LN-89083", "in_service", 11000.5, 6800),
|
||||||
|
("RA-73703", "Boeing", "737-800", "Россия", "LN-73703", "in_service", 17000.0, 9500),
|
||||||
|
("RA-73704", "Boeing", "737-800", "Россия", "LN-73704", "in_service", 15000.5, 8800),
|
||||||
|
("RA-89052", "Airbus", "A320", "Уральские авиалинии", "MSN-89052", "in_service", 19000.0, 10500),
|
||||||
|
("RA-89053", "Airbus", "A320", "Уральские авиалинии", "MSN-89053", "in_service", 17500.5, 9800),
|
||||||
|
# Вертолёты (seed_helicopters): Ми-8, Ми-26, Ми-171, Ка-32, Ка-226, Ансат
|
||||||
|
("RA-12345", "Миль", "Ми-8", "Аэрофлот - Российские авиалинии", "08-001", "in_service", 15000.0, 8500),
|
||||||
|
("RA-12346", "Миль", "Ми-8", "S7 Airlines", "08-002", "in_service", 12000.5, 7200),
|
||||||
|
("RA-12347", "Миль", "Ми-8", "Уральские авиалинии", "08-003", "maintenance", 18000.0, 10200),
|
||||||
|
("RA-12348", "Миль", "Ми-8", "Аэрофлот - Российские авиалинии", "08-004", "in_service", 14000.0, 8000),
|
||||||
|
("RA-12349", "Миль", "Ми-8", "S7 Airlines", "08-005", "in_service", 16000.5, 9200),
|
||||||
|
("RA-12450", "Миль", "Ми-26", "Аэрофлот - Российские авиалинии", "26-001", "in_service", 20000.0, 5500),
|
||||||
|
("RA-12451", "Миль", "Ми-26", "Уральские авиалинии", "26-002", "in_service", 18500.5, 5100),
|
||||||
|
("RA-12550", "Миль", "Ми-171", "Аэрофлот - Российские авиалинии", "171-001", "in_service", 8000.0, 4500),
|
||||||
|
("RA-12551", "Миль", "Ми-171", "S7 Airlines", "171-002", "in_service", 7500.5, 4200),
|
||||||
|
("RA-12552", "Миль", "Ми-171", "Уральские авиалинии", "171-003", "in_service", 9200.0, 5100),
|
||||||
|
("RA-12650", "Камов", "Ка-32", "Аэрофлот - Российские авиалинии", "32-001", "in_service", 11000.0, 6500),
|
||||||
|
("RA-12651", "Камов", "Ка-32", "S7 Airlines", "32-002", "maintenance", 13000.5, 7500),
|
||||||
|
("RA-12750", "Камов", "Ка-226", "Аэрофлот - Российские авиалинии", "226-001", "in_service", 5000.0, 3000),
|
||||||
|
("RA-12751", "Камов", "Ка-226", "Уральские авиалинии", "226-002", "in_service", 4500.5, 2800),
|
||||||
|
("RA-12850", "Казанский вертолетный завод", "Ансат", "Аэрофлот - Российские авиалинии", "ANS-001", "in_service", 3000.0, 2000),
|
||||||
|
("RA-12851", "Казанский вертолетный завод", "Ансат", "S7 Airlines", "ANS-002", "in_service", 2800.5, 1900),
|
||||||
|
("RA-12852", "Казанский вертолетный завод", "Ансат", "Уральские авиалинии", "ANS-003", "in_service", 3200.0, 2100),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_type_id(db, manufacturer: str, model: str) -> str | None:
|
||||||
|
t = db.query(AircraftType).filter(
|
||||||
|
AircraftType.manufacturer == manufacturer, AircraftType.model == model
|
||||||
|
).first()
|
||||||
|
return str(t.id) if t else None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_operator_id(db, name: str) -> str | None:
|
||||||
|
o = db.query(Organization).filter(
|
||||||
|
Organization.name == name, Organization.kind == "operator"
|
||||||
|
).first()
|
||||||
|
return str(o.id) if o else None
|
||||||
|
|
||||||
|
|
||||||
|
def seed_aircraft_demo():
|
||||||
|
db = SessionLocal()
|
||||||
|
created = 0
|
||||||
|
skipped = 0
|
||||||
|
try:
|
||||||
|
for reg, manuf, model, op_name, serial, status, t_h, t_c in _AIRCRAFT:
|
||||||
|
if db.query(Aircraft).filter(Aircraft.registration_number == reg).first():
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
type_id = _get_type_id(db, manuf, model)
|
||||||
|
op_id = _get_operator_id(db, op_name)
|
||||||
|
if not type_id or not op_id:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
db.add(Aircraft(
|
||||||
|
registration_number=reg,
|
||||||
|
aircraft_type_id=type_id,
|
||||||
|
operator_id=op_id,
|
||||||
|
serial_number=serial,
|
||||||
|
current_status=status,
|
||||||
|
total_time=float(t_h),
|
||||||
|
total_cycles=int(t_c),
|
||||||
|
))
|
||||||
|
created += 1
|
||||||
|
db.commit()
|
||||||
|
total = db.query(Aircraft).count()
|
||||||
|
print(f"✅ ВС: добавлено {created}, пропущено {skipped}, всего {total}")
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
seed_aircraft_demo()
|
||||||
113
backend/app/db/seed_aircraft_types.py
Normal file
113
backend/app/db/seed_aircraft_types.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
"""
|
||||||
|
Скрипт для заполнения справочника типов воздушных судов.
|
||||||
|
|
||||||
|
Заполняет справочник популярными типами ВС согласно требованиям ИКАО.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.aircraft import AircraftType
|
||||||
|
|
||||||
|
|
||||||
|
def seed_aircraft_types():
|
||||||
|
"""Заполнить справочник типами воздушных судов."""
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
# Популярные типы воздушных судов
|
||||||
|
aircraft_types = [
|
||||||
|
# Boeing
|
||||||
|
{"manufacturer": "Boeing", "model": "737-800"},
|
||||||
|
{"manufacturer": "Boeing", "model": "737-900"},
|
||||||
|
{"manufacturer": "Boeing", "model": "737 MAX 8"},
|
||||||
|
{"manufacturer": "Boeing", "model": "777-300ER"},
|
||||||
|
{"manufacturer": "Boeing", "model": "787-8 Dreamliner"},
|
||||||
|
{"manufacturer": "Boeing", "model": "787-9 Dreamliner"},
|
||||||
|
|
||||||
|
# Airbus
|
||||||
|
{"manufacturer": "Airbus", "model": "A320"},
|
||||||
|
{"manufacturer": "Airbus", "model": "A320neo"},
|
||||||
|
{"manufacturer": "Airbus", "model": "A321"},
|
||||||
|
{"manufacturer": "Airbus", "model": "A321neo"},
|
||||||
|
{"manufacturer": "Airbus", "model": "A330-300"},
|
||||||
|
{"manufacturer": "Airbus", "model": "A350-900"},
|
||||||
|
{"manufacturer": "Airbus", "model": "A350-1000"},
|
||||||
|
|
||||||
|
# Sukhoi Superjet
|
||||||
|
{"manufacturer": "Sukhoi", "model": "Superjet 100"},
|
||||||
|
{"manufacturer": "Sukhoi", "model": "Superjet 100-95"},
|
||||||
|
{"manufacturer": "Sukhoi", "model": "Superjet 100-95LR"},
|
||||||
|
|
||||||
|
# Иркут МС-21
|
||||||
|
{"manufacturer": "Иркут", "model": "МС-21-300"},
|
||||||
|
{"manufacturer": "Иркут", "model": "МС-21-310"},
|
||||||
|
|
||||||
|
# Bombardier
|
||||||
|
{"manufacturer": "Bombardier", "model": "CRJ-900"},
|
||||||
|
{"manufacturer": "Bombardier", "model": "CRJ-1000"},
|
||||||
|
|
||||||
|
# Embraer
|
||||||
|
{"manufacturer": "Embraer", "model": "E-170"},
|
||||||
|
{"manufacturer": "Embraer", "model": "E-175"},
|
||||||
|
{"manufacturer": "Embraer", "model": "E-190"},
|
||||||
|
{"manufacturer": "Embraer", "model": "E-195"},
|
||||||
|
|
||||||
|
# ATR
|
||||||
|
{"manufacturer": "ATR", "model": "72-600"},
|
||||||
|
{"manufacturer": "ATR", "model": "42-600"},
|
||||||
|
|
||||||
|
# Антонов
|
||||||
|
{"manufacturer": "Антонов", "model": "Ан-148"},
|
||||||
|
{"manufacturer": "Антонов", "model": "Ан-158"},
|
||||||
|
|
||||||
|
# Ильюшин
|
||||||
|
{"manufacturer": "Ильюшин", "model": "Ил-96-300"},
|
||||||
|
{"manufacturer": "Ильюшин", "model": "Ил-96-400"},
|
||||||
|
{"manufacturer": "Ильюшин", "model": "Ил-114-300"},
|
||||||
|
|
||||||
|
# Ту
|
||||||
|
{"manufacturer": "Туполев", "model": "Ту-204"},
|
||||||
|
{"manufacturer": "Туполев", "model": "Ту-214"},
|
||||||
|
{"manufacturer": "Туполев", "model": "Ту-334"},
|
||||||
|
# Вертолёты
|
||||||
|
{"manufacturer": "Миль", "model": "Ми-8"},
|
||||||
|
{"manufacturer": "Миль", "model": "Ми-26"},
|
||||||
|
{"manufacturer": "Миль", "model": "Ми-171"},
|
||||||
|
{"manufacturer": "Камов", "model": "Ка-32"},
|
||||||
|
{"manufacturer": "Камов", "model": "Ка-226"},
|
||||||
|
{"manufacturer": "Казанский вертолетный завод", "model": "Ансат"},
|
||||||
|
]
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
for at_data in aircraft_types:
|
||||||
|
# Проверяем, существует ли уже такой тип
|
||||||
|
existing = db.query(AircraftType).filter(
|
||||||
|
AircraftType.manufacturer == at_data["manufacturer"],
|
||||||
|
AircraftType.model == at_data["model"]
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Создаем новый тип
|
||||||
|
at = AircraftType(**at_data)
|
||||||
|
db.add(at)
|
||||||
|
created_count += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print(f"✅ Создано типов ВС: {created_count}")
|
||||||
|
print(f"⏭️ Пропущено (уже существуют): {skipped_count}")
|
||||||
|
print(f"📊 Всего в справочнике: {db.query(AircraftType).count()}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
print(f"❌ Ошибка при заполнении справочника: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
seed_aircraft_types()
|
||||||
122
backend/app/db/seed_helicopters.sql
Normal file
122
backend/app/db/seed_helicopters.sql
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
-- Скрипт для добавления вертолетов в реестр Российской Федерации
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
-- ID операторов
|
||||||
|
aeroflot_id TEXT;
|
||||||
|
s7_id TEXT;
|
||||||
|
ural_id TEXT;
|
||||||
|
rosaviation_id TEXT;
|
||||||
|
|
||||||
|
-- ID типов вертолетов
|
||||||
|
mi8_id TEXT;
|
||||||
|
mi26_id TEXT;
|
||||||
|
ka32_id TEXT;
|
||||||
|
ansat_id TEXT;
|
||||||
|
mi171_id TEXT;
|
||||||
|
ka226_id TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- Получаем ID операторов
|
||||||
|
SELECT id INTO aeroflot_id FROM organizations WHERE name = 'Аэрофлот - Российские авиалинии' AND kind = 'operator' LIMIT 1;
|
||||||
|
SELECT id INTO s7_id FROM organizations WHERE name = 'S7 Airlines' AND kind = 'operator' LIMIT 1;
|
||||||
|
SELECT id INTO ural_id FROM organizations WHERE name = 'Уральские авиалинии' AND kind = 'operator' LIMIT 1;
|
||||||
|
SELECT id INTO rosaviation_id FROM organizations WHERE name = 'Федеральное агентство воздушного транспорта (Росавиация)' AND kind = 'authority' LIMIT 1;
|
||||||
|
|
||||||
|
-- Добавляем типы вертолетов, если их еще нет
|
||||||
|
INSERT INTO aircraft_types (id, manufacturer, model, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'Миль', 'Ми-8', NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'Миль', 'Ми-26', NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'Миль', 'Ми-171', NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'Камов', 'Ка-32', NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'Камов', 'Ка-226', NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'Казанский вертолетный завод', 'Ансат', NOW(), NOW())
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Получаем ID типов вертолетов
|
||||||
|
SELECT id INTO mi8_id FROM aircraft_types WHERE manufacturer = 'Миль' AND model = 'Ми-8' LIMIT 1;
|
||||||
|
SELECT id INTO mi26_id FROM aircraft_types WHERE manufacturer = 'Миль' AND model = 'Ми-26' LIMIT 1;
|
||||||
|
SELECT id INTO mi171_id FROM aircraft_types WHERE manufacturer = 'Миль' AND model = 'Ми-171' LIMIT 1;
|
||||||
|
SELECT id INTO ka32_id FROM aircraft_types WHERE manufacturer = 'Камов' AND model = 'Ка-32' LIMIT 1;
|
||||||
|
SELECT id INTO ka226_id FROM aircraft_types WHERE manufacturer = 'Камов' AND model = 'Ка-226' LIMIT 1;
|
||||||
|
SELECT id INTO ansat_id FROM aircraft_types WHERE manufacturer = 'Казанский вертолетный завод' AND model = 'Ансат' LIMIT 1;
|
||||||
|
|
||||||
|
-- Добавляем вертолеты Ми-8
|
||||||
|
IF mi8_id IS NOT NULL THEN
|
||||||
|
INSERT INTO aircraft (id, registration_number, aircraft_type_id, operator_id, serial_number, current_status, total_time, total_cycles, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'RA-12345', mi8_id, COALESCE(aeroflot_id, (SELECT id FROM organizations WHERE kind = 'operator' LIMIT 1)), '08-001', 'in_service', 15000.0, 8500, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-12346', mi8_id, COALESCE(s7_id, (SELECT id FROM organizations WHERE kind = 'operator' LIMIT 1)), '08-002', 'in_service', 12000.5, 7200, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-12347', mi8_id, COALESCE(ural_id, (SELECT id FROM organizations WHERE kind = 'operator' LIMIT 1)), '08-003', 'maintenance', 18000.0, 10200, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-12348', mi8_id, COALESCE(aeroflot_id, (SELECT id FROM organizations WHERE kind = 'operator' LIMIT 1)), '08-004', 'in_service', 14000.0, 8000, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-12349', mi8_id, COALESCE(s7_id, (SELECT id FROM organizations WHERE kind = 'operator' LIMIT 1)), '08-005', 'in_service', 16000.5, 9200, NOW(), NOW())
|
||||||
|
ON CONFLICT (registration_number) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Добавляем вертолеты Ми-26
|
||||||
|
IF mi26_id IS NOT NULL THEN
|
||||||
|
INSERT INTO aircraft (id, registration_number, aircraft_type_id, operator_id, serial_number, current_status, total_time, total_cycles, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'RA-12450', mi26_id, COALESCE(aeroflot_id, (SELECT id FROM organizations WHERE kind = 'operator' LIMIT 1)), '26-001', 'in_service', 20000.0, 5500, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-12451', mi26_id, COALESCE(ural_id, (SELECT id FROM organizations WHERE kind = 'operator' LIMIT 1)), '26-002', 'in_service', 18500.5, 5100, NOW(), NOW())
|
||||||
|
ON CONFLICT (registration_number) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Добавляем вертолеты Ми-171
|
||||||
|
IF mi171_id IS NOT NULL THEN
|
||||||
|
INSERT INTO aircraft (id, registration_number, aircraft_type_id, operator_id, serial_number, current_status, total_time, total_cycles, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'RA-12550', mi171_id, COALESCE(aeroflot_id, (SELECT id FROM organizations WHERE kind = 'operator' LIMIT 1)), '171-001', 'in_service', 8000.0, 4500, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-12551', mi171_id, COALESCE(s7_id, (SELECT id FROM organizations WHERE kind = 'operator' LIMIT 1)), '171-002', 'in_service', 7500.5, 4200, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-12552', mi171_id, COALESCE(ural_id, (SELECT id FROM organizations WHERE kind = 'operator' LIMIT 1)), '171-003', 'in_service', 9200.0, 5100, NOW(), NOW())
|
||||||
|
ON CONFLICT (registration_number) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Добавляем вертолеты Ка-32
|
||||||
|
IF ka32_id IS NOT NULL THEN
|
||||||
|
INSERT INTO aircraft (id, registration_number, aircraft_type_id, operator_id, serial_number, current_status, total_time, total_cycles, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'RA-12650', ka32_id, COALESCE(aeroflot_id, (SELECT id FROM organizations WHERE kind = 'operator' LIMIT 1)), '32-001', 'in_service', 11000.0, 6500, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-12651', ka32_id, COALESCE(s7_id, (SELECT id FROM organizations WHERE kind = 'operator' LIMIT 1)), '32-002', 'maintenance', 13000.5, 7500, NOW(), NOW())
|
||||||
|
ON CONFLICT (registration_number) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Добавляем вертолеты Ка-226
|
||||||
|
IF ka226_id IS NOT NULL THEN
|
||||||
|
INSERT INTO aircraft (id, registration_number, aircraft_type_id, operator_id, serial_number, current_status, total_time, total_cycles, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'RA-12750', ka226_id, COALESCE(aeroflot_id, (SELECT id FROM organizations WHERE kind = 'operator' LIMIT 1)), '226-001', 'in_service', 5000.0, 3000, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-12751', ka226_id, COALESCE(ural_id, (SELECT id FROM organizations WHERE kind = 'operator' LIMIT 1)), '226-002', 'in_service', 4500.5, 2800, NOW(), NOW())
|
||||||
|
ON CONFLICT (registration_number) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Добавляем вертолеты Ансат
|
||||||
|
IF ansat_id IS NOT NULL THEN
|
||||||
|
INSERT INTO aircraft (id, registration_number, aircraft_type_id, operator_id, serial_number, current_status, total_time, total_cycles, created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
(gen_random_uuid()::text, 'RA-12850', ansat_id, COALESCE(aeroflot_id, (SELECT id FROM organizations WHERE kind = 'operator' LIMIT 1)), 'ANS-001', 'in_service', 3000.0, 2000, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-12851', ansat_id, COALESCE(s7_id, (SELECT id FROM organizations WHERE kind = 'operator' LIMIT 1)), 'ANS-002', 'in_service', 2800.5, 1900, NOW(), NOW()),
|
||||||
|
(gen_random_uuid()::text, 'RA-12852', ansat_id, COALESCE(ural_id, (SELECT id FROM organizations WHERE kind = 'operator' LIMIT 1)), 'ANS-003', 'in_service', 3200.0, 2100, NOW(), NOW())
|
||||||
|
ON CONFLICT (registration_number) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Проверка результата
|
||||||
|
SELECT
|
||||||
|
at.manufacturer || ' ' || at.model as helicopter_type,
|
||||||
|
COUNT(a.id) as count,
|
||||||
|
STRING_AGG(a.registration_number, ', ' ORDER BY a.registration_number) as registration_numbers
|
||||||
|
FROM aircraft_types at
|
||||||
|
JOIN aircraft a ON a.aircraft_type_id = at.id
|
||||||
|
WHERE at.manufacturer IN ('Миль', 'Камов', 'Казанский вертолетный завод')
|
||||||
|
GROUP BY at.id, at.manufacturer, at.model
|
||||||
|
ORDER BY at.manufacturer, at.model;
|
||||||
|
|
||||||
|
-- Общая статистика
|
||||||
|
SELECT
|
||||||
|
'Всего вертолетов в реестре' as info,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM aircraft a
|
||||||
|
JOIN aircraft_types at ON a.aircraft_type_id = at.id
|
||||||
|
WHERE at.manufacturer IN ('Миль', 'Камов', 'Казанский вертолетный завод');
|
||||||
36
backend/app/db/seed_legal.py
Normal file
36
backend/app/db/seed_legal.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Начальное заполнение справочников для модуля юридических документов:
|
||||||
|
юрисдикции (страны/регионы). Запуск: python -m app.db.seed_legal
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.db.session import SessionLocal
|
||||||
|
from app.models import Jurisdiction
|
||||||
|
|
||||||
|
|
||||||
|
JURISDICTIONS = [
|
||||||
|
{"code": "RU", "name": "Russian Federation", "name_ru": "Российская Федерация", "description": "Законодательство РФ"},
|
||||||
|
{"code": "KZ", "name": "Republic of Kazakhstan", "name_ru": "Республика Казахстан", "description": "Законодательство РК"},
|
||||||
|
{"code": "BY", "name": "Republic of Belarus", "name_ru": "Республика Беларусь", "description": "Законодательство РБ"},
|
||||||
|
{"code": "EU", "name": "European Union", "name_ru": "Европейский союз", "description": "Право ЕС (директивы, регламенты)"},
|
||||||
|
{"code": "US", "name": "United States", "name_ru": "США", "description": "Федеральное и штатное право США"},
|
||||||
|
{"code": "US-CA", "name": "California (US)", "name_ru": "Калифорния (США)", "description": "Право штата Калифорния"},
|
||||||
|
{"code": "ICAO", "name": "ICAO", "name_ru": "ИКАО", "description": "Стандарты и рекомендуемая практика ИКАО"},
|
||||||
|
{"code": "EASA", "name": "EASA", "name_ru": "ЕАСА", "description": "Европейское агентство авиационной безопасности"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def seed_jurisdictions():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
for j in JURISDICTIONS:
|
||||||
|
if db.query(Jurisdiction).filter(Jurisdiction.code == j["code"]).first():
|
||||||
|
continue
|
||||||
|
db.add(Jurisdiction(**j))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
seed_jurisdictions()
|
||||||
|
print("Legal jurisdictions seeded.")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user