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:
Yuriy 2026-02-08 17:18:31 +03:00
commit 0150aba4f5
334 changed files with 57536 additions and 0 deletions

23
.gitignore vendored Normal file
View 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/

Binary file not shown.

172
README.md Normal file
View 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. **Интеграции:**
- Уточнить контракты П‑ИВ: форматы сообщений, расписания, ETLpipeline, протоколирование.
- Подключить централизованную НСИ через П‑НСИ (справочники типов ВС, статусы, классификаторы).
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» согласно техническому заданию.

View 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);
});
});

View 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();
});
});
});

View 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();
});
});
});

View 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
View 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
View 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
View 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
View 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\рикреплены файлы: ${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
View 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
View 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
View 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
View 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',
});
}
}

View 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',
});
}
}

View 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',
});
}
}

View 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',
});
}
}

View 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
View 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
View 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
View 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
View 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
View 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',
});
}
}

View 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' });
}
}

View 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: [] });
}
}

View 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 });
}

View 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
View 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',
});
}
}

View 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 }
);
}
}

View 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',
});
}
}

View 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',
});
}
}

View 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
View 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);
}

View 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',
});
}
}

View 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',
});
}
}

View 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',
});
}
}

View 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 }
);
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()

View 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
View File

@ -0,0 +1 @@
from app.core.config import settings # noqa: F401

View File

83
backend/app/api/deps.py Normal file
View 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

View 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": "Внутренняя ошибка сервера. Обратитесь к администратору.",
},
)

View 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",
]

View 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

View 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

View 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()
)

View 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 []

View 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

View 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)

View 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

View File

@ -0,0 +1,8 @@
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/health")
def health():
return {"status": "ok"}

View 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}

View 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"
}

View 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", {}),
)

View 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()

View 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

View 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

View 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)

View 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
]

View 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,
)

View 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",
}

View File

View 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()

View 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)")

View File

5
backend/app/db/base.py Normal file
View File

@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

13
backend/app/db/init_db.py Normal file
View 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()

View 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;

View 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()

View 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()

View 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 ('Миль', 'Камов', 'Казанский вертолетный завод');

View 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