From 0f82eef08d68f7a4dabc40d606d78f27be66cc0a Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Fri, 24 Oct 2025 16:19:58 +0300 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20MVP:=20FastAPI=20+=20React=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D0=B0=20=D1=81=20SMS=20=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✅ Инфраструктура: PostgreSQL, Redis, RabbitMQ, S3 ✅ Backend: SMS сервис + API endpoints ✅ Frontend: React форма (3 шага) + SMS верификация --- .gitignore | 1 + LINKS.md | 41 +++ PROJECT_ARCHITECTURE.md | 1 + QUICK_START.md | 1 + README.md | 1 + START_HERE.md | 173 ++++++++++ backend/Dockerfile | 21 ++ backend/app/api/__init__.py | 4 + backend/app/api/claims.py | 51 +++ backend/app/api/models.py | 64 ++++ backend/app/api/sms.py | 53 +++ backend/app/config.py | 125 ++++++- backend/app/main.py | 246 +++++++------- backend/app/services/__init__.py | 4 + backend/app/services/database.py | 76 +++++ backend/app/services/rabbitmq_service.py | 226 +++++++++++++ backend/app/services/redis_service.py | 146 +++++++++ backend/app/services/sms_service.py | 180 +++++++++++ backend/db/init.sql | 306 ++++++++++++++++++ docker-compose.full.yml | 137 ++++++++ docker-compose.yml | 64 ++++ frontend/Dockerfile | 26 ++ frontend/index.html | 15 + frontend/package.json | 6 +- frontend/public/index.html | 1 + frontend/src/App.css | 1 + frontend/src/App.tsx | 118 +------ frontend/src/components/form/Step1Phone.tsx | 199 ++++++++++++ frontend/src/components/form/Step2Details.tsx | 122 +++++++ frontend/src/components/form/Step3Payment.tsx | 131 ++++++++ frontend/src/index.css | 1 + frontend/src/main.tsx | 1 + frontend/src/pages/ClaimForm.css | 51 +++ frontend/src/pages/ClaimForm.tsx | 147 +++++++++ frontend/tsconfig.json | 1 + frontend/tsconfig.node.json | 12 + frontend/vite.config.ts | 1 + index.html | 164 ++++++++++ links.html | 118 +++++++ start_backend.sh | 6 + start_frontend.sh | 5 + ЗАПУСК.md | 96 ++++++ 42 files changed, 2902 insertions(+), 241 deletions(-) create mode 100644 LINKS.md create mode 100644 START_HERE.md create mode 100644 backend/Dockerfile create mode 100644 backend/app/api/claims.py create mode 100644 backend/app/api/models.py create mode 100644 backend/app/api/sms.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/database.py create mode 100644 backend/app/services/rabbitmq_service.py create mode 100644 backend/app/services/redis_service.py create mode 100644 backend/app/services/sms_service.py create mode 100644 backend/db/init.sql create mode 100644 docker-compose.full.yml create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/src/components/form/Step1Phone.tsx create mode 100644 frontend/src/components/form/Step2Details.tsx create mode 100644 frontend/src/components/form/Step3Payment.tsx create mode 100644 frontend/src/pages/ClaimForm.css create mode 100644 frontend/src/pages/ClaimForm.tsx create mode 100644 frontend/tsconfig.node.json create mode 100644 index.html create mode 100644 links.html create mode 100755 start_backend.sh create mode 100755 start_frontend.sh create mode 100644 ЗАПУСК.md diff --git a/.gitignore b/.gitignore index 8d3ef0f..d12660f 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ htmlcov/ *.tmp *.bak + diff --git a/LINKS.md b/LINKS.md new file mode 100644 index 0000000..cca775c --- /dev/null +++ b/LINKS.md @@ -0,0 +1,41 @@ +# 🔗 ССЫЛКИ ДЛЯ ДОСТУПА + +## После запуска открывай эти адреса: + +### Frontend (React приложение): +http://147.45.146.17:5173/ + +### Backend API: +http://147.45.146.17:8100/ + +### API Документация (Swagger): +http://147.45.146.17:8100/docs + +### Health Check: +http://147.45.146.17:8100/health + +### Test Endpoint: +http://147.45.146.17:8100/api/v1/test + +### Gitea (Git репозиторий): +http://147.45.146.17:3002/negodiy/erv-platform + +--- + +## Команды для запуска: + +### Терминал 1 - Backend: +```bash +cd /var/www/fastuser/data/www/crm.clientright.ru/erv_platform/backend +source venv/bin/activate +uvicorn app.main:app --reload --host 0.0.0.0 --port 8100 +``` + +### Терминал 2 - Frontend: +```bash +cd /var/www/fastuser/data/www/crm.clientright.ru/erv_platform/frontend +npm install +npm run dev +``` + + diff --git a/PROJECT_ARCHITECTURE.md b/PROJECT_ARCHITECTURE.md index 854e621..0f4b02d 100644 --- a/PROJECT_ARCHITECTURE.md +++ b/PROJECT_ARCHITECTURE.md @@ -584,3 +584,4 @@ server { Сейчас создам всю базовую структуру и запущу оба приложения (FastAPI + React). **Начинаю прямо сейчас!** 🚀 + diff --git a/QUICK_START.md b/QUICK_START.md index f78a382..02ca49f 100644 --- a/QUICK_START.md +++ b/QUICK_START.md @@ -181,3 +181,4 @@ git push -u origin main **Удачи!** 🚀 + diff --git a/README.md b/README.md index b588033..1fefa29 100644 --- a/README.md +++ b/README.md @@ -170,3 +170,4 @@ git push origin main **Автор**: AI Assistant + Фёдор **Дата**: 24.10.2025 + diff --git a/START_HERE.md b/START_HERE.md new file mode 100644 index 0000000..0d034bd --- /dev/null +++ b/START_HERE.md @@ -0,0 +1,173 @@ +# ⚡ ЗАПУСК MVP - ИНСТРУКЦИЯ ДЛЯ ФЁДОРА + +## 🎯 Что сделано: + +✅ FastAPI backend (Python) +✅ React frontend (TypeScript) +✅ Git репозиторий (Gitea) +✅ Конфигурация (.env) + +--- + +## 🚀 КАК ЗАПУСТИТЬ (2 команды): + +### **Команда 1: Backend (FastAPI)** + +Открой **ТЕРМИНАЛ 1** и выполни: + +```bash +cd /var/www/fastuser/data/www/crm.clientright.ru/erv_platform/backend +source venv/bin/activate +uvicorn app.main:app --reload --host 0.0.0.0 --port 8100 +``` + +Увидишь: +``` +🚀 ERV Insurance Platform запускается... +📍 Backend URL: http://localhost:8100 +📍 API Docs: http://localhost:8100/docs +INFO: Uvicorn running on http://0.0.0.0:8100 +``` + +**НЕ ЗАКРЫВАЙ этот терминал!** Сервер должен работать. + +--- + +### **Команда 2: Frontend (React)** + +Открой **ТЕРМИНАЛ 2** (новый!) и выполни: + +```bash +cd /var/www/fastuser/data/www/crm.clientright.ru/erv_platform/frontend +npm install +npm run dev +``` + +Увидишь: +``` +VITE v5.x.x ready in XXX ms +➜ Local: http://localhost:5173/ +➜ Network: http://147.45.146.17:5173/ +``` + +**НЕ ЗАКРЫВАЙ этот терминал!** Сервер должен работать. + +--- + +## 🌐 ОТКРОЙ В БРАУЗЕРЕ: + +### **1. Frontend (главная страница):** +``` +http://147.45.146.17:5173/ +``` + +**Увидишь:** +- ✅ Информацию о платформе +- ✅ Статус всех сервисов (Redis, PostgreSQL, OCR) +- ✅ Список возможностей +- ✅ Технологический стек + +### **2. API Документация (Swagger UI):** +``` +http://147.45.146.17:8100/docs +``` + +**Увидишь:** +- ✅ Список всех API endpoints +- ✅ Можно тестировать прямо в браузере! +- ✅ Автоматическая документация + +### **3. Health Check:** +``` +http://147.45.146.17:8100/health +``` + +**Увидишь:** +- ✅ Статус каждого сервиса (Redis, PostgreSQL, OCR) +- ✅ OK или ERROR для каждого + +--- + +## 🐛 Если что-то не работает: + +### **Backend не запускается?** + +```bash +# Проверь порт 8100 свободен +netstat -tuln | grep 8100 + +# Если занят - используй другой порт: +uvicorn app.main:app --reload --host 0.0.0.0 --port 8200 +# Тогда меняй везде 8100 на 8200 +``` + +### **Frontend не запускается?** + +```bash +# Проверь Node.js версию +node --version + +# Если < 18, обнови: +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt-get install -y nodejs +``` + +### **Нет соединения между Frontend и Backend?** + +Проверь в `frontend/vite.config.ts`: +```typescript +proxy: { + '/api': { + target: 'http://localhost:8100', ← Должен совпадать с портом backend + } +} +``` + +--- + +## ✅ Проверка что всё работает: + +После запуска **ОБОИХ** серверов, проверь: + +1. ✅ `http://147.45.146.17:8100/` → должен вернуть JSON +2. ✅ `http://147.45.146.17:8100/health` → статус сервисов +3. ✅ `http://147.45.146.17:5173/` → красивая страница с информацией + +--- + +## 📊 Что дальше: + +После того как убедишься что **МВП работает**: + +1. Скажешь мне: "Работает!" или "Не работает, вот ошибка..." +2. Если работает → я продолжу создавать полную функциональность: + - API для OCR документов + - API для проверки рейсов + - React компоненты формы + - Автозаполнение + - WebSocket real-time + - И т.д. + +--- + +## 🎁 Бонус - полезные команды: + +```bash +# Остановить Backend +# Ctrl+C в терминале где запущен uvicorn + +# Остановить Frontend +# Ctrl+C в терминале где запущен npm run dev + +# Посмотреть логи Backend +tail -f /var/www/fastuser/data/www/crm.clientright.ru/erv_platform/logs/backend.log + +# Gitea репозиторий +http://147.45.146.17:3002/negodiy/erv-platform +``` + +--- + +**ЗАПУСКАЙ И ПИШИ ЧТО ПОЛУЧИЛОСЬ!** 🚀 + + diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..7a79c64 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,21 @@ +# Python FastAPI Backend Dockerfile +FROM python:3.10-slim + +# Устанавливаем рабочую директорию +WORKDIR /app + +# Копируем requirements.txt +COPY requirements.txt . + +# Устанавливаем зависимости +RUN pip install --no-cache-dir -r requirements.txt + +# Копируем исходный код +COPY . . + +# Открываем порт +EXPOSE 8100 + +# Запускаем приложение +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8100"] + diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index e69de29..f2e214b 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -0,0 +1,4 @@ +""" +API Routes +""" + diff --git a/backend/app/api/claims.py b/backend/app/api/claims.py new file mode 100644 index 0000000..6a455f4 --- /dev/null +++ b/backend/app/api/claims.py @@ -0,0 +1,51 @@ +""" +Claims API Routes - Обработка заявок +""" +from fastapi import APIRouter, HTTPException +from .models import ClaimCreateRequest, ClaimResponse +import uuid +from datetime import datetime + +router = APIRouter(prefix="/api/v1/claims", tags=["Claims"]) + + +@router.post("/create", response_model=ClaimResponse) +async def create_claim(claim: ClaimCreateRequest): + """ + Создать новую заявку + + Принимает данные формы и создает заявку в системе + """ + try: + # Генерируем ID и номер заявки + claim_id = str(uuid.uuid4()) + claim_number = f"ERV-{datetime.now().strftime('%Y%m%d')}-{claim_id[:8].upper()}" + + # TODO: Сохранить в PostgreSQL + # TODO: Отправить в очередь RabbitMQ для обработки + # TODO: Интеграция с CRM + + return ClaimResponse( + success=True, + claim_id=claim_id, + claim_number=claim_number, + message=f"Заявка {claim_number} успешно создана" + ) + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Ошибка при создании заявки: {str(e)}" + ) + + +@router.get("/{claim_id}") +async def get_claim(claim_id: str): + """Получить информацию о заявке по ID""" + # TODO: Получить из БД + return { + "claim_id": claim_id, + "status": "processing", + "message": "Заявка в обработке" + } + diff --git a/backend/app/api/models.py b/backend/app/api/models.py new file mode 100644 index 0000000..81a2421 --- /dev/null +++ b/backend/app/api/models.py @@ -0,0 +1,64 @@ +""" +Pydantic модели для API +""" +from pydantic import BaseModel, Field, field_validator +from typing import Optional, List +from datetime import date + + +class SMSSendRequest(BaseModel): + """Запрос на отправку SMS кода""" + phone: str = Field(..., description="Номер телефона в формате +79001234567") + + @field_validator('phone') + @classmethod + def validate_phone(cls, v: str) -> str: + # Убираем все кроме цифр и + + clean = ''.join(c for c in v if c.isdigit() or c == '+') + if not clean.startswith('+'): + clean = '+' + clean + if len(clean) != 12: # +7 + 10 цифр + raise ValueError('Неверный формат телефона') + return clean + + +class SMSVerifyRequest(BaseModel): + """Запрос на проверку SMS кода""" + phone: str = Field(..., description="Номер телефона") + code: str = Field(..., min_length=6, max_length=6, description="6-значный код") + + +class ClaimCreateRequest(BaseModel): + """Запрос на создание заявки""" + # Шаг 1: Основная информация + phone: str + email: Optional[str] = None + inn: Optional[str] = None + policy_number: str + policy_series: Optional[str] = None + + # Шаг 2: Данные о происшествии + incident_date: Optional[str] = None + incident_description: Optional[str] = None + transport_type: Optional[str] = None # "air", "train", "bus", etc. + + # Шаг 3: Данные для выплаты + payment_method: str = "sbp" # "sbp", "card", "bank_transfer" + bank_name: Optional[str] = None + card_number: Optional[str] = None + account_number: Optional[str] = None + + # Файлы (UUID после загрузки) + uploaded_files: Optional[List[str]] = [] + + # Метаданные + source: str = "web_form" + + +class ClaimResponse(BaseModel): + """Ответ после создания заявки""" + success: bool + claim_id: Optional[str] = None + claim_number: Optional[str] = None + message: str + diff --git a/backend/app/api/sms.py b/backend/app/api/sms.py new file mode 100644 index 0000000..f7ebf62 --- /dev/null +++ b/backend/app/api/sms.py @@ -0,0 +1,53 @@ +""" +SMS API Routes +""" +from fastapi import APIRouter, HTTPException +from ..services.sms_service import sms_service +from .models import SMSSendRequest, SMSVerifyRequest + +router = APIRouter(prefix="/api/v1/sms", tags=["SMS"]) + + +@router.post("/send") +async def send_sms_code(request: SMSSendRequest): + """ + Отправить SMS код верификации + + - **phone**: Номер телефона в формате +79001234567 + """ + code = await sms_service.send_verification_code(request.phone) + + if code: + return { + "success": True, + "message": "Код отправлен на указанный номер", + "debug_code": code if sms_service.enabled else None # Показываем код только в dev + } + else: + raise HTTPException( + status_code=429, + detail="Слишком много запросов. Попробуйте через минуту." + ) + + +@router.post("/verify") +async def verify_sms_code(request: SMSVerifyRequest): + """ + Проверить SMS код + + - **phone**: Номер телефона + - **code**: 6-значный код из SMS + """ + is_valid = await sms_service.verify_code(request.phone, request.code) + + if is_valid: + return { + "success": True, + "message": "Код подтвержден" + } + else: + raise HTTPException( + status_code=400, + detail="Неверный код или код истек" + ) + diff --git a/backend/app/config.py b/backend/app/config.py index ccd8988..bba2d74 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -3,10 +3,13 @@ """ from pydantic_settings import BaseSettings from functools import lru_cache +from typing import List class Settings(BaseSettings): - # App + # ============================================ + # APPLICATION + # ============================================ app_name: str = "ERV Insurance Platform" app_env: str = "development" debug: bool = True @@ -16,47 +19,144 @@ class Settings(BaseSettings): backend_url: str = "http://localhost:8100" frontend_url: str = "http://localhost:5173" - # PostgreSQL + # ============================================ + # DATABASE (PostgreSQL) + # ============================================ postgres_host: str = "147.45.189.234" postgres_port: int = 5432 postgres_db: str = "default_db" postgres_user: str = "gen_user" postgres_password: str = "2~~9_^kVsU?2\\S" - # Redis + @property + def database_url(self) -> str: + """Формирует URL для подключения к PostgreSQL""" + return f"postgresql+asyncpg://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}:{self.postgres_port}/{self.postgres_db}" + + # ============================================ + # REDIS + # ============================================ redis_host: str = "localhost" redis_port: int = 6379 redis_password: str = "CRM_Redis_Pass_2025_Secure!" redis_db: int = 0 redis_prefix: str = "erv:" - # RabbitMQ + @property + def redis_url(self) -> str: + """Формирует URL для подключения к Redis""" + if self.redis_password: + return f"redis://:{self.redis_password}@{self.redis_host}:{self.redis_port}/{self.redis_db}" + return f"redis://{self.redis_host}:{self.redis_port}/{self.redis_db}" + + # ============================================ + # RABBITMQ + # ============================================ rabbitmq_host: str = "185.197.75.249" rabbitmq_port: int = 5672 rabbitmq_user: str = "admin" rabbitmq_password: str = "tyejvtej" rabbitmq_vhost: str = "/" - # OCR Service - ocr_api_url: str = "http://147.45.146.17:8001" + @property + def rabbitmq_url(self) -> str: + """Формирует URL для подключения к RabbitMQ""" + return f"amqp://{self.rabbitmq_user}:{self.rabbitmq_password}@{self.rabbitmq_host}:{self.rabbitmq_port}{self.rabbitmq_vhost}" - # OpenRouter AI + # ============================================ + # S3 STORAGE (Timeweb Cloud Storage) + # ============================================ + s3_endpoint: str = "https://s3.timeweb.com" + s3_bucket: str = "erv-platform-files" + s3_access_key: str = "your_access_key_here" + s3_secret_key: str = "your_secret_key_here" + s3_region: str = "ru-1" + + # ============================================ + # OCR SERVICE + # ============================================ + ocr_api_url: str = "http://147.45.146.17:8001" + ocr_api_key: str = "" + + # ============================================ + # AI SERVICE (OpenRouter) + # ============================================ openrouter_api_key: str = "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" + openrouter_base_url: str = "https://openrouter.ai/api/v1" openrouter_model: str = "google/gemini-2.0-flash-001" + # ============================================ + # FLIGHT APIs + # ============================================ # FlightAware flightaware_api_key: str = "Puz0cdxAHzAEqMRZwtdeqBUSm9naJfwK" + flightaware_base_url: str = "https://aeroapi.flightaware.com/aeroapi" + # AviationStack (резервный) + aviationstack_api_key: str = "" + aviationstack_base_url: str = "http://api.aviationstack.com/v1" + + # ============================================ + # NSPK BANKS API + # ============================================ + nspk_banks_api_url: str = "https://qr.nspk.ru/proxyapp/c2bmembers.json" + + # ============================================ + # SMS SERVICE (SigmaSMS) + # ============================================ + sms_api_url: str = "https://online.sigmasms.ru/api/" + sms_login: str = "" + sms_password: str = "" + sms_token: str = "" + sms_sender: str = "lexpriority" + sms_enabled: bool = True + + # ============================================ + # VTIGER CRM (PHP Bridge) + # ============================================ + crm_webservice_url: str = "http://crm.clientright.ru/webservice.php" + crm_webform_url: str = "https://crm.clientright.ru/modules/Webforms/capture.php" + crm_token: str = "" + + # ============================================ + # RATE LIMITING + # ============================================ + rate_limit_per_minute: int = 60 + rate_limit_per_hour: int = 1000 + + # ============================================ + # FILE UPLOAD + # ============================================ + max_upload_size_mb: int = 50 + allowed_file_extensions: str = "pdf,jpg,jpeg,png,heic,heif,webp" + + @property + def allowed_extensions_list(self) -> List[str]: + """Список разрешенных расширений файлов""" + return [ext.strip() for ext in self.allowed_file_extensions.split(",")] + + # ============================================ # CORS - cors_origins: list = [ - "http://localhost:5173", - "http://147.45.146.17:5173", - "https://erv-claims.clientright.ru" - ] + # ============================================ + cors_origins: str = "http://localhost:5173,http://147.45.146.17:5173,https://erv-claims.clientright.ru,http://crm.clientright.ru" + + @property + def cors_origins_list(self) -> List[str]: + """Список CORS origins""" + if isinstance(self.cors_origins, str): + return [origin.strip() for origin in self.cors_origins.split(",")] + return self.cors_origins + + # ============================================ + # LOGGING + # ============================================ + log_level: str = "INFO" + log_file: str = "/app/logs/erv_platform.log" class Config: env_file = "../.env" case_sensitive = False + extra = "ignore" # Игнорируем лишние поля из .env @lru_cache() @@ -66,3 +166,4 @@ def get_settings() -> Settings: settings = get_settings() + diff --git a/backend/app/main.py b/backend/app/main.py index 2eacf5b..1a316f8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -3,33 +3,84 @@ ERV Insurance Platform - FastAPI Backend """ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse -from app.config import settings -import redis -import asyncpg +from contextlib import asynccontextmanager +import logging + +from .config import settings +from .services.database import db +from .services.redis_service import redis_service +from .services.rabbitmq_service import rabbitmq_service +from .api import sms, claims + +# Настройка логирования +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Lifecycle events: startup and shutdown + """ + # STARTUP + logger.info("🚀 Starting ERV Platform...") + + try: + # Подключаем PostgreSQL + await db.connect() + except Exception as e: + logger.warning(f"⚠️ PostgreSQL not available: {e}") + + try: + # Подключаем Redis + await redis_service.connect() + except Exception as e: + logger.warning(f"⚠️ Redis not available: {e}") + + try: + # Подключаем RabbitMQ + await rabbitmq_service.connect() + except Exception as e: + logger.warning(f"⚠️ RabbitMQ not available: {e}") + + logger.info("✅ ERV Platform started successfully!") + + yield + + # SHUTDOWN + logger.info("🛑 Shutting down ERV Platform...") + + await db.disconnect() + await redis_service.disconnect() + await rabbitmq_service.disconnect() + + logger.info("👋 ERV Platform stopped") + # Создаём FastAPI приложение app = FastAPI( title="ERV Insurance Platform API", - description="API для обработки страховых обращений с OCR, AI и интеграциями", + description="API для обработки страховых обращений", version="1.0.0", - docs_url="/docs", - redoc_url="/redoc" + lifespan=lifespan ) -# CORS middleware +# CORS app.add_middleware( CORSMiddleware, - allow_origins=settings.cors_origins, + allow_origins=settings.cors_origins_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) +# API Routes +app.include_router(sms.router) +app.include_router(claims.router) -# ============================================ -# HEALTH CHECKS -# ============================================ @app.get("/") async def root(): @@ -37,140 +88,111 @@ async def root(): return { "message": "🚀 ERV Insurance Platform API", "version": "1.0.0", - "docs": f"{settings.backend_url}/docs", - "status": "running" + "status": "running", + "docs": "http://147.45.146.17:8100/docs" } @app.get("/health") -async def health_check(): - """Проверка здоровья сервисов""" +async def health(): + """Health check - проверка всех сервисов""" health_status = { - "api": "ok", - "redis": "checking", - "postgres": "checking", - "ocr": "checking" + "status": "ok", + "message": "API работает!", + "services": {} } - # Проверка Redis - try: - r = redis.Redis( - host=settings.redis_host, - port=settings.redis_port, - password=settings.redis_password, - decode_responses=True - ) - r.ping() - health_status["redis"] = "ok" - except Exception as e: - health_status["redis"] = f"error: {str(e)}" - # Проверка PostgreSQL try: - conn = await asyncpg.connect( - host=settings.postgres_host, - port=settings.postgres_port, - database=settings.postgres_db, - user=settings.postgres_user, - password=settings.postgres_password - ) - await conn.execute("SELECT 1") - await conn.close() - health_status["postgres"] = "ok" - except Exception as e: - health_status["postgres"] = f"error: {str(e)}" - - # Проверка OCR - import httpx - try: - async with httpx.AsyncClient() as client: - response = await client.get(f"{settings.ocr_api_url}/", timeout=5.0) - health_status["ocr"] = "ok" if response.status_code in [200, 404] else "unreachable" - except Exception as e: - health_status["ocr"] = f"error: {str(e)}" - - all_ok = all(v == "ok" for v in health_status.values()) - - return JSONResponse( - status_code=200 if all_ok else 503, - content={ - "status": "healthy" if all_ok else "degraded", - "services": health_status + pg_healthy = await db.health_check() + health_status["services"]["postgresql"] = { + "status": "✅ healthy" if pg_healthy else "❌ unhealthy", + "connected": pg_healthy } + except: + health_status["services"]["postgresql"] = { + "status": "❌ unavailable", + "connected": False + } + + # Проверка Redis + try: + redis_healthy = await redis_service.health_check() + health_status["services"]["redis"] = { + "status": "✅ healthy" if redis_healthy else "❌ unhealthy", + "connected": redis_healthy + } + except: + health_status["services"]["redis"] = { + "status": "❌ unavailable", + "connected": False + } + + # Проверка RabbitMQ + try: + rabbitmq_healthy = await rabbitmq_service.health_check() + health_status["services"]["rabbitmq"] = { + "status": "✅ healthy" if rabbitmq_healthy else "❌ unhealthy", + "connected": rabbitmq_healthy + } + except: + health_status["services"]["rabbitmq"] = { + "status": "❌ unavailable", + "connected": False + } + + # Общий статус + all_healthy = all( + service.get("connected", False) + for service in health_status["services"].values() ) + + if not all_healthy: + health_status["status"] = "degraded" + health_status["message"] = "⚠️ Некоторые сервисы недоступны" + + return health_status -# ============================================ -# API V1 ENDPOINTS -# ============================================ - @app.get("/api/v1/test") -async def test_endpoint(): +async def test(): """Тестовый endpoint""" return { - "message": "✅ API работает!", - "env": settings.app_env, - "debug": settings.debug, + "success": True, + "message": "✅ Backend API работает!", "services": { - "redis": f"{settings.redis_host}:{settings.redis_port}", - "postgres": f"{settings.postgres_host}:{settings.postgres_port}", - "rabbitmq": f"{settings.rabbitmq_host}:{settings.rabbitmq_port}", - "ocr": settings.ocr_api_url + "redis": "localhost:6379", + "postgres": "147.45.189.234:5432", + "ocr": "147.45.146.17:8001", + "rabbitmq": "185.197.75.249:5672" } } @app.get("/api/v1/info") -async def get_info(): +async def info(): """Информация о платформе""" return { "platform": "ERV Insurance Claims", "version": "1.0.0", - "features": [ - "OCR документов (паспорт, билеты)", - "AI автозаполнение (Gemini Vision)", - "Проверка рейсов (FlightAware)", - "СБП выплаты", - "Интеграция с CRM" - ], "tech_stack": { "backend": "Python FastAPI", "frontend": "React TypeScript", "database": "PostgreSQL + MySQL", "cache": "Redis", "queue": "RabbitMQ", - "storage": "S3 Timeweb", - "ocr": "Internal Service", - "ai": "OpenRouter Gemini 2.0" - } + "storage": "S3 Timeweb" + }, + "features": [ + "OCR документов (паспорт, билеты)", + "AI автозаполнение (Gemini Vision)", + "Проверка рейсов (FlightAware)", + "СБП выплаты", + "Интеграция с CRM Vtiger" + ] } -# ============================================ -# STARTUP/SHUTDOWN -# ============================================ - -@app.on_event("startup") -async def startup_event(): - """При старте приложения""" - print("🚀 ERV Insurance Platform запускается...") - print(f"📍 Backend URL: {settings.backend_url}") - print(f"📍 API Docs: {settings.backend_url}/docs") - print(f"🔗 Frontend URL: {settings.frontend_url}") - - -@app.on_event("shutdown") -async def shutdown_event(): - """При остановке приложения""" - print("👋 ERV Insurance Platform остановлен") - - if __name__ == "__main__": import uvicorn - uvicorn.run( - "main:app", - host="0.0.0.0", - port=8100, - reload=True - ) - + uvicorn.run(app, host="0.0.0.0", port=8100) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..23aff87 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1,4 @@ +""" +ERV Platform Services +""" + diff --git a/backend/app/services/database.py b/backend/app/services/database.py new file mode 100644 index 0000000..d13f307 --- /dev/null +++ b/backend/app/services/database.py @@ -0,0 +1,76 @@ +""" +PostgreSQL Database Service +""" +import asyncpg +from typing import Optional, Dict, Any, List +from ..config import settings +import logging + +logger = logging.getLogger(__name__) + + +class DatabaseService: + """Сервис для работы с PostgreSQL""" + + def __init__(self): + self.pool: Optional[asyncpg.Pool] = None + + async def connect(self): + """Создает пул подключений к PostgreSQL""" + try: + self.pool = await asyncpg.create_pool( + host=settings.postgres_host, + port=settings.postgres_port, + database=settings.postgres_db, + user=settings.postgres_user, + password=settings.postgres_password, + min_size=5, + max_size=20, + command_timeout=60 + ) + logger.info(f"✅ PostgreSQL connected: {settings.postgres_host}:{settings.postgres_port}/{settings.postgres_db}") + except Exception as e: + logger.error(f"❌ PostgreSQL connection error: {e}") + raise + + async def disconnect(self): + """Закрывает пул подключений""" + if self.pool: + await self.pool.close() + logger.info("PostgreSQL pool closed") + + async def execute(self, query: str, *args) -> str: + """Выполняет SQL запрос без возврата данных""" + async with self.pool.acquire() as conn: + return await conn.execute(query, *args) + + async def fetch_one(self, query: str, *args) -> Optional[Dict[str, Any]]: + """Возвращает одну запись""" + async with self.pool.acquire() as conn: + row = await conn.fetchrow(query, *args) + return dict(row) if row else None + + async def fetch_all(self, query: str, *args) -> List[Dict[str, Any]]: + """Возвращает все записи""" + async with self.pool.acquire() as conn: + rows = await conn.fetch(query, *args) + return [dict(row) for row in rows] + + async def fetch_val(self, query: str, *args): + """Возвращает одно значение""" + async with self.pool.acquire() as conn: + return await conn.fetchval(query, *args) + + async def health_check(self) -> bool: + """Проверка здоровья БД""" + try: + result = await self.fetch_val("SELECT 1") + return result == 1 + except Exception as e: + logger.error(f"Database health check failed: {e}") + return False + + +# Глобальный экземпляр +db = DatabaseService() + diff --git a/backend/app/services/rabbitmq_service.py b/backend/app/services/rabbitmq_service.py new file mode 100644 index 0000000..d2fb0bf --- /dev/null +++ b/backend/app/services/rabbitmq_service.py @@ -0,0 +1,226 @@ +""" +RabbitMQ Service для асинхронной обработки задач +""" +import aio_pika +from aio_pika import Connection, Channel, Queue, Exchange, Message +from aio_pika.pool import Pool +from typing import Optional, Callable, Dict, Any +import json +import logging +from ..config import settings + +logger = logging.getLogger(__name__) + + +class RabbitMQService: + """Сервис для работы с RabbitMQ""" + + # Названия очередей + QUEUE_OCR_PROCESSING = "erv_ocr_processing" + QUEUE_AI_EXTRACTION = "erv_ai_extraction" + QUEUE_FLIGHT_CHECK = "erv_flight_check" + QUEUE_CRM_INTEGRATION = "erv_crm_integration" + QUEUE_NOTIFICATIONS = "erv_notifications" + + def __init__(self): + self.connection: Optional[Connection] = None + self.channel: Optional[Channel] = None + self.queues: Dict[str, Queue] = {} + + async def connect(self): + """Подключение к RabbitMQ""" + try: + self.connection = await aio_pika.connect_robust( + settings.rabbitmq_url, + timeout=30 + ) + self.channel = await self.connection.channel() + await self.channel.set_qos(prefetch_count=10) + + logger.info(f"✅ RabbitMQ connected: {settings.rabbitmq_host}:{settings.rabbitmq_port}") + + # Объявляем очереди + await self._declare_queues() + + except Exception as e: + logger.error(f"❌ RabbitMQ connection error: {e}") + raise + + async def disconnect(self): + """Отключение от RabbitMQ""" + if self.connection: + await self.connection.close() + logger.info("RabbitMQ connection closed") + + async def _declare_queues(self): + """Объявляем все рабочие очереди""" + queue_names = [ + self.QUEUE_OCR_PROCESSING, + self.QUEUE_AI_EXTRACTION, + self.QUEUE_FLIGHT_CHECK, + self.QUEUE_CRM_INTEGRATION, + self.QUEUE_NOTIFICATIONS, + ] + + for queue_name in queue_names: + queue = await self.channel.declare_queue( + queue_name, + durable=True, # Очередь переживет перезапуск + arguments={ + "x-message-ttl": 3600000, # TTL сообщений 1 час + "x-max-length": 10000, # Максимум сообщений в очереди + } + ) + self.queues[queue_name] = queue + logger.info(f"✅ Queue declared: {queue_name}") + + async def publish( + self, + queue_name: str, + message: Dict[str, Any], + priority: int = 5, + headers: Optional[Dict[str, Any]] = None + ): + """ + Публикация сообщения в очередь + + Args: + queue_name: Название очереди + message: Данные сообщения (dict) + priority: Приоритет (0-10, где 10 - максимальный) + headers: Дополнительные заголовки + """ + try: + msg_body = json.dumps(message).encode() + + msg = Message( + body=msg_body, + priority=priority, + headers=headers or {}, + content_type="application/json", + delivery_mode=aio_pika.DeliveryMode.PERSISTENT # Сохранять на диск + ) + + # Публикуем в default exchange с routing_key = queue_name + await self.channel.default_exchange.publish( + msg, + routing_key=queue_name + ) + + logger.debug(f"📤 Message published to {queue_name}: {message.get('task_id', 'unknown')}") + + except Exception as e: + logger.error(f"❌ Failed to publish message to {queue_name}: {e}") + raise + + async def consume( + self, + queue_name: str, + callback: Callable, + prefetch_count: int = 1 + ): + """ + Подписка на сообщения из очереди + + Args: + queue_name: Название очереди + callback: Асинхронная функция-обработчик + prefetch_count: Количество сообщений для одновременной обработки + """ + try: + queue = self.queues.get(queue_name) + if not queue: + logger.error(f"Queue {queue_name} not found") + return + + await self.channel.set_qos(prefetch_count=prefetch_count) + + await queue.consume(callback) + logger.info(f"👂 Consuming from {queue_name}") + + except Exception as e: + logger.error(f"❌ Failed to consume from {queue_name}: {e}") + raise + + async def health_check(self) -> bool: + """Проверка здоровья RabbitMQ""" + try: + if self.connection and not self.connection.is_closed: + return True + return False + except Exception as e: + logger.error(f"RabbitMQ health check failed: {e}") + return False + + # ============================================ + # ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ДЛЯ ЗАДАЧ + # ============================================ + + async def publish_ocr_task(self, claim_id: str, file_id: str, file_path: str): + """Отправка задачи на OCR обработку""" + await self.publish( + self.QUEUE_OCR_PROCESSING, + { + "task_type": "ocr_processing", + "claim_id": claim_id, + "file_id": file_id, + "file_path": file_path + }, + priority=8 + ) + + async def publish_ai_extraction_task(self, claim_id: str, file_id: str, ocr_text: str): + """Отправка задачи на AI извлечение данных""" + await self.publish( + self.QUEUE_AI_EXTRACTION, + { + "task_type": "ai_extraction", + "claim_id": claim_id, + "file_id": file_id, + "ocr_text": ocr_text + }, + priority=7 + ) + + async def publish_flight_check_task(self, claim_id: str, flight_number: str, flight_date: str): + """Отправка задачи на проверку рейса""" + await self.publish( + self.QUEUE_FLIGHT_CHECK, + { + "task_type": "flight_check", + "claim_id": claim_id, + "flight_number": flight_number, + "flight_date": flight_date + }, + priority=6 + ) + + async def publish_crm_integration_task(self, claim_id: str, form_data: Dict[str, Any]): + """Отправка задачи на интеграцию с CRM""" + await self.publish( + self.QUEUE_CRM_INTEGRATION, + { + "task_type": "crm_integration", + "claim_id": claim_id, + "form_data": form_data + }, + priority=9 # Высокий приоритет + ) + + async def publish_notification_task(self, claim_id: str, notification_type: str, data: Dict[str, Any]): + """Отправка задачи на отправку уведомления""" + await self.publish( + self.QUEUE_NOTIFICATIONS, + { + "task_type": "notification", + "claim_id": claim_id, + "notification_type": notification_type, + "data": data + }, + priority=5 + ) + + +# Глобальный экземпляр +rabbitmq_service = RabbitMQService() + diff --git a/backend/app/services/redis_service.py b/backend/app/services/redis_service.py new file mode 100644 index 0000000..71a2db7 --- /dev/null +++ b/backend/app/services/redis_service.py @@ -0,0 +1,146 @@ +""" +Redis Service для кеширования, rate limiting, сессий +""" +import redis.asyncio as redis +from typing import Optional, Any +import json +from ..config import settings +import logging + +logger = logging.getLogger(__name__) + + +class RedisService: + """Сервис для работы с Redis""" + + def __init__(self): + self.client: Optional[redis.Redis] = None + + async def connect(self): + """Подключение к Redis""" + try: + self.client = await redis.from_url( + settings.redis_url, + encoding="utf-8", + decode_responses=True + ) + await self.client.ping() + logger.info(f"✅ Redis connected: {settings.redis_host}:{settings.redis_port}") + except Exception as e: + logger.error(f"❌ Redis connection error: {e}") + raise + + async def disconnect(self): + """Отключение от Redis""" + if self.client: + await self.client.close() + logger.info("Redis connection closed") + + async def get(self, key: str) -> Optional[str]: + """Получить значение по ключу""" + full_key = f"{settings.redis_prefix}{key}" + return await self.client.get(full_key) + + async def set(self, key: str, value: Any, expire: Optional[int] = None): + """Установить значение с опциональным TTL (в секундах)""" + full_key = f"{settings.redis_prefix}{key}" + if isinstance(value, (dict, list)): + value = json.dumps(value) + if expire: + await self.client.setex(full_key, expire, value) + else: + await self.client.set(full_key, value) + + async def delete(self, key: str) -> bool: + """Удалить ключ""" + full_key = f"{settings.redis_prefix}{key}" + result = await self.client.delete(full_key) + return result > 0 + + async def exists(self, key: str) -> bool: + """Проверить существование ключа""" + full_key = f"{settings.redis_prefix}{key}" + return await self.client.exists(full_key) > 0 + + async def increment(self, key: str, amount: int = 1) -> int: + """Инкремент значения""" + full_key = f"{settings.redis_prefix}{key}" + return await self.client.incrby(full_key, amount) + + async def expire(self, key: str, seconds: int): + """Установить TTL для ключа""" + full_key = f"{settings.redis_prefix}{key}" + await self.client.expire(full_key, seconds) + + async def get_json(self, key: str) -> Optional[dict]: + """Получить JSON значение""" + value = await self.get(key) + if value: + try: + return json.loads(value) + except json.JSONDecodeError: + return None + return None + + async def set_json(self, key: str, value: dict, expire: Optional[int] = None): + """Установить JSON значение""" + await self.set(key, json.dumps(value), expire) + + async def health_check(self) -> bool: + """Проверка здоровья Redis""" + try: + return await self.client.ping() + except Exception as e: + logger.error(f"Redis health check failed: {e}") + return False + + # ============================================ + # RATE LIMITING + # ============================================ + + async def check_rate_limit(self, identifier: str, max_requests: int, window_seconds: int) -> tuple[bool, int]: + """ + Проверка rate limiting + Returns: (allowed: bool, remaining: int) + """ + key = f"ratelimit:{identifier}" + full_key = f"{settings.redis_prefix}{key}" + + current = await self.client.get(full_key) + + if current is None: + # Первый запрос в окне + await self.client.setex(full_key, window_seconds, 1) + return True, max_requests - 1 + + current_count = int(current) + + if current_count >= max_requests: + # Лимит превышен + ttl = await self.client.ttl(full_key) + return False, 0 + + # Инкремент счетчика + new_count = await self.client.incr(full_key) + return True, max_requests - new_count + + # ============================================ + # CACHE + # ============================================ + + async def cache_get(self, cache_key: str) -> Optional[Any]: + """Получить из кеша""" + return await self.get_json(f"cache:{cache_key}") + + async def cache_set(self, cache_key: str, value: Any, ttl: int = 3600): + """Сохранить в кеш (TTL по умолчанию 1 час)""" + await self.set_json(f"cache:{cache_key}", value, ttl) + + async def cache_delete(self, cache_key: str): + """Удалить из кеша""" + await self.delete(f"cache:{cache_key}") + + +# Глобальный экземпляр +redis_service = RedisService() + diff --git a/backend/app/services/sms_service.py b/backend/app/services/sms_service.py new file mode 100644 index 0000000..5980481 --- /dev/null +++ b/backend/app/services/sms_service.py @@ -0,0 +1,180 @@ +""" +SMS Service для отправки кодов верификации (SigmaSMS) +""" +import httpx +import random +import logging +from typing import Optional +from ..config import settings +from .redis_service import redis_service + +logger = logging.getLogger(__name__) + + +class SMSService: + """Сервис для работы с SMS через SigmaSMS API""" + + def __init__(self): + self.api_url = settings.sms_api_url + self.login = settings.sms_login + self.password = settings.sms_password + self.token = settings.sms_token + self.sender = settings.sms_sender + self.enabled = settings.sms_enabled + + async def _get_token(self) -> Optional[str]: + """Получить JWT токен для API""" + try: + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.api_url}login", + json={ + "username": self.login, + "password": self.password + }, + timeout=10.0 + ) + + if response.status_code == 200: + data = response.json() + return data.get("token") + else: + logger.error(f"Failed to get SMS token: {response.status_code}") + return self.token # Используем токен из .env как fallback + + except Exception as e: + logger.error(f"Error getting SMS token: {e}") + return self.token + + def generate_code(self) -> str: + """Генерировать 6-значный код""" + return str(random.randint(100000, 999999)) + + async def send_sms(self, phone: str, message: str) -> bool: + """ + Отправить SMS + + Args: + phone: Номер телефона (формат: +79001234567) + message: Текст сообщения + + Returns: + bool: True если отправлено успешно + """ + if not self.enabled: + logger.warning("SMS отправка отключена в конфигурации") + return False + + try: + # Получаем актуальный токен + token = await self._get_token() + + if not token: + logger.error("No SMS token available") + return False + + # Очищаем номер телефона + clean_phone = phone.replace("+", "").replace("-", "").replace(" ", "") + + # Отправляем SMS + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.api_url}sendings", + headers={ + "Authorization": f"Bearer {token}" + }, + json={ + "recipient": clean_phone, + "type": "sms", + "payload": { + "sender": self.sender, + "text": message + } + }, + timeout=15.0 + ) + + if response.status_code in [200, 201]: + logger.info(f"✅ SMS sent to {phone}") + return True + else: + logger.error(f"Failed to send SMS: {response.status_code} - {response.text}") + return False + + except Exception as e: + logger.error(f"Error sending SMS: {e}") + return False + + async def send_verification_code(self, phone: str) -> Optional[str]: + """ + Отправить код верификации на телефон + + Args: + phone: Номер телефона + + Returns: + str: Код верификации (для отладки) или None при ошибке + """ + # Проверка rate limiting (не больше 1 SMS в минуту на номер) + rate_limit_key = f"sms_rate:{phone}" + + if await redis_service.exists(rate_limit_key): + ttl = await redis_service.client.ttl(f"{settings.redis_prefix}{rate_limit_key}") + logger.warning(f"Rate limit for {phone}, retry in {ttl} seconds") + return None + + # Генерируем код + code = self.generate_code() + + # Сохраняем код в Redis на 10 минут + verification_key = f"sms_verify:{phone}" + await redis_service.set(verification_key, code, expire=600) # 10 минут + + # Устанавливаем rate limit на 60 секунд + await redis_service.set(rate_limit_key, "1", expire=60) + + # Формируем сообщение + message = f"Ваш код подтверждения: {code}. Действителен 10 минут." + + # Отправляем SMS + success = await self.send_sms(phone, message) + + if success: + logger.info(f"Verification code sent to {phone}") + return code # Возвращаем для отладки + else: + # Удаляем код если не удалось отправить + await redis_service.delete(verification_key) + return None + + async def verify_code(self, phone: str, code: str) -> bool: + """ + Проверить код верификации + + Args: + phone: Номер телефона + code: Код для проверки + + Returns: + bool: True если код верный + """ + verification_key = f"sms_verify:{phone}" + stored_code = await redis_service.get(verification_key) + + if not stored_code: + logger.warning(f"No verification code found for {phone}") + return False + + if stored_code == code: + # Удаляем код после успешной проверки + await redis_service.delete(verification_key) + logger.info(f"✅ Code verified for {phone}") + return True + else: + logger.warning(f"Invalid code for {phone}") + return False + + +# Глобальный экземпляр +sms_service = SMSService() + diff --git a/backend/db/init.sql b/backend/db/init.sql new file mode 100644 index 0000000..033a274 --- /dev/null +++ b/backend/db/init.sql @@ -0,0 +1,306 @@ +-- ERV Platform Database Initialization Script +-- PostgreSQL 16+ + +-- Enable extensions +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; + +-- ============================================ +-- ТАБЛИЦА: claims (Основные заявки) +-- ============================================ +CREATE TABLE IF NOT EXISTS claims ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + claim_number VARCHAR(50) UNIQUE NOT NULL, + + -- Тип страхования + insurance_type VARCHAR(50) NOT NULL DEFAULT 'erv_travel', + case_type VARCHAR(100), -- например: 'flight_delay', 'medical', 'baggage_loss' + + -- Данные клиента + client_phone VARCHAR(20) NOT NULL, + client_email VARCHAR(255), + client_inn VARCHAR(12), + client_full_name VARCHAR(500), + + -- Данные полиса + policy_number VARCHAR(100), + policy_series VARCHAR(50), + + -- Статус обработки + status VARCHAR(50) NOT NULL DEFAULT 'draft', -- draft, processing, crm_sent, completed, error + crm_id VARCHAR(100), -- ID в Vtiger CRM + + -- Данные для аналитики + source VARCHAR(100), -- откуда пришла заявка: 'web_form', 'api', 'mobile_app' + user_agent TEXT, + ip_address INET, + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + submitted_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE, + + -- JSON поля для гибкости + form_data JSONB, -- все данные формы + metadata JSONB -- дополнительные метаданные +); + +-- Индексы для claims +CREATE INDEX idx_claims_claim_number ON claims(claim_number); +CREATE INDEX idx_claims_status ON claims(status); +CREATE INDEX idx_claims_created_at ON claims(created_at DESC); +CREATE INDEX idx_claims_client_phone ON claims(client_phone); +CREATE INDEX idx_claims_policy_number ON claims(policy_number); +CREATE INDEX idx_claims_insurance_type ON claims(insurance_type); +CREATE INDEX idx_claims_form_data_gin ON claims USING gin(form_data); + +-- ============================================ +-- ТАБЛИЦА: claim_files (Файлы к заявкам) +-- ============================================ +CREATE TABLE IF NOT EXISTS claim_files ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + claim_id UUID NOT NULL REFERENCES claims(id) ON DELETE CASCADE, + + -- Файл + file_name VARCHAR(255) NOT NULL, + file_path VARCHAR(500) NOT NULL, + file_size BIGINT, + mime_type VARCHAR(100), + file_type VARCHAR(50), -- 'passport', 'ticket', 'receipt', 'medical_doc', etc. + + -- S3 данные (если используется) + s3_bucket VARCHAR(255), + s3_key VARCHAR(500), + s3_url TEXT, + + -- OCR/AI обработка + ocr_status VARCHAR(50), -- 'pending', 'processing', 'completed', 'error' + ocr_text TEXT, + ai_extracted_data JSONB, + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + processed_at TIMESTAMP WITH TIME ZONE +); + +-- Индексы для claim_files +CREATE INDEX idx_claim_files_claim_id ON claim_files(claim_id); +CREATE INDEX idx_claim_files_file_type ON claim_files(file_type); +CREATE INDEX idx_claim_files_ocr_status ON claim_files(ocr_status); + +-- ============================================ +-- ТАБЛИЦА: processing_logs (Логи обработки) +-- ============================================ +CREATE TABLE IF NOT EXISTS processing_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + claim_id UUID REFERENCES claims(id) ON DELETE CASCADE, + + -- Лог + level VARCHAR(20) NOT NULL, -- 'debug', 'info', 'warning', 'error', 'critical' + message TEXT NOT NULL, + context JSONB, -- дополнительный контекст + + -- Источник + source VARCHAR(100), -- 'ocr_service', 'ai_service', 'flight_api', 'crm_integration', etc. + function_name VARCHAR(200), + + -- Ошибки + error_type VARCHAR(100), + error_traceback TEXT, + + -- Timestamp + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Индексы для processing_logs +CREATE INDEX idx_processing_logs_claim_id ON processing_logs(claim_id); +CREATE INDEX idx_processing_logs_level ON processing_logs(level); +CREATE INDEX idx_processing_logs_created_at ON processing_logs(created_at DESC); +CREATE INDEX idx_processing_logs_source ON processing_logs(source); + +-- ============================================ +-- ТАБЛИЦА: api_calls (Логи внешних API вызовов) +-- ============================================ +CREATE TABLE IF NOT EXISTS api_calls ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + claim_id UUID REFERENCES claims(id) ON DELETE SET NULL, + + -- API детали + api_name VARCHAR(100) NOT NULL, -- 'ocr_service', 'openrouter_ai', 'flightaware', 'nspk_banks', etc. + endpoint VARCHAR(500), + method VARCHAR(10), -- 'GET', 'POST', etc. + + -- Запрос + request_headers JSONB, + request_body JSONB, + + -- Ответ + response_status INTEGER, + response_headers JSONB, + response_body JSONB, + + -- Производительность + duration_ms INTEGER, -- длительность запроса в миллисекундах + + -- Результат + success BOOLEAN DEFAULT FALSE, + error_message TEXT, + + -- Timestamp + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Индексы для api_calls +CREATE INDEX idx_api_calls_claim_id ON api_calls(claim_id); +CREATE INDEX idx_api_calls_api_name ON api_calls(api_name); +CREATE INDEX idx_api_calls_success ON api_calls(success); +CREATE INDEX idx_api_calls_created_at ON api_calls(created_at DESC); + +-- ============================================ +-- ТАБЛИЦА: queue_tasks (Задачи в очереди RabbitMQ) +-- ============================================ +CREATE TABLE IF NOT EXISTS queue_tasks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + claim_id UUID REFERENCES claims(id) ON DELETE CASCADE, + + -- Задача + task_type VARCHAR(100) NOT NULL, -- 'ocr_processing', 'ai_extraction', 'flight_check', etc. + queue_name VARCHAR(100) NOT NULL, + priority INTEGER DEFAULT 5, -- 1-10, где 10 - максимальный приоритет + + -- Данные + task_data JSONB, + + -- Статус + status VARCHAR(50) NOT NULL DEFAULT 'pending', -- pending, processing, completed, failed, retry + attempts INTEGER DEFAULT 0, + max_attempts INTEGER DEFAULT 3, + + -- Результат + result JSONB, + error_message TEXT, + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + started_at TIMESTAMP WITH TIME ZONE, + completed_at TIMESTAMP WITH TIME ZONE, + next_retry_at TIMESTAMP WITH TIME ZONE +); + +-- Индексы для queue_tasks +CREATE INDEX idx_queue_tasks_claim_id ON queue_tasks(claim_id); +CREATE INDEX idx_queue_tasks_status ON queue_tasks(status); +CREATE INDEX idx_queue_tasks_task_type ON queue_tasks(task_type); +CREATE INDEX idx_queue_tasks_created_at ON queue_tasks(created_at DESC); +CREATE INDEX idx_queue_tasks_next_retry ON queue_tasks(next_retry_at) WHERE status = 'retry'; + +-- ============================================ +-- ТАБЛИЦА: metrics (Метрики системы) +-- ============================================ +CREATE TABLE IF NOT EXISTS metrics ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Метрика + metric_name VARCHAR(100) NOT NULL, + metric_value NUMERIC(20, 4), + metric_unit VARCHAR(50), -- 'ms', 'count', 'bytes', '%', etc. + + -- Теги для группировки + tags JSONB, + + -- Timestamp + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Индексы для metrics +CREATE INDEX idx_metrics_metric_name ON metrics(metric_name); +CREATE INDEX idx_metrics_created_at ON metrics(created_at DESC); +CREATE INDEX idx_metrics_tags_gin ON metrics USING gin(tags); + +-- ============================================ +-- ТАБЛИЦА: cache_entries (Кеш для редких запросов) +-- ============================================ +CREATE TABLE IF NOT EXISTS cache_entries ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Ключ-значение + cache_key VARCHAR(255) UNIQUE NOT NULL, + cache_value JSONB, + + -- TTL + expires_at TIMESTAMP WITH TIME ZONE, + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Индексы для cache_entries +CREATE INDEX idx_cache_entries_key ON cache_entries(cache_key); +CREATE INDEX idx_cache_entries_expires ON cache_entries(expires_at); + +-- Автоматическая очистка устаревшего кеша +CREATE OR REPLACE FUNCTION cleanup_expired_cache() +RETURNS void AS $$ +BEGIN + DELETE FROM cache_entries WHERE expires_at < CURRENT_TIMESTAMP; +END; +$$ LANGUAGE plpgsql; + +-- ============================================ +-- ТРИГГЕРЫ +-- ============================================ + +-- Автоматическое обновление updated_at для claims +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_claims_updated_at + BEFORE UPDATE ON claims + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- ============================================ +-- ПРЕДСТАВЛЕНИЯ (VIEWS) +-- ============================================ + +-- Статистика по заявкам +CREATE OR REPLACE VIEW claims_statistics AS +SELECT + DATE(created_at) as date, + insurance_type, + status, + COUNT(*) as count, + AVG(EXTRACT(EPOCH FROM (completed_at - created_at))) as avg_processing_time_seconds +FROM claims +GROUP BY DATE(created_at), insurance_type, status; + +-- Статистика по API вызовам +CREATE OR REPLACE VIEW api_performance AS +SELECT + api_name, + DATE(created_at) as date, + COUNT(*) as total_calls, + SUM(CASE WHEN success THEN 1 ELSE 0 END) as successful_calls, + AVG(duration_ms) as avg_duration_ms, + MAX(duration_ms) as max_duration_ms +FROM api_calls +GROUP BY api_name, DATE(created_at); + +-- ============================================ +-- НАЧАЛЬНЫЕ ДАННЫЕ +-- ============================================ + +-- Можно добавить тестовые данные для разработки +-- INSERT INTO claims (claim_number, client_phone, insurance_type, status) +-- VALUES ('TEST-001', '+79001234567', 'erv_travel', 'draft'); + +-- Завершение +SELECT 'Database initialized successfully!' as message; + diff --git a/docker-compose.full.yml b/docker-compose.full.yml new file mode 100644 index 0000000..3ac3f11 --- /dev/null +++ b/docker-compose.full.yml @@ -0,0 +1,137 @@ +version: '3.8' + +services: + # PostgreSQL для логов, метрик, аналитики + postgres: + image: postgres:16-alpine + container_name: erv_postgres + restart: unless-stopped + environment: + POSTGRES_DB: erv_platform + POSTGRES_USER: erv_user + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-erv_secure_pass_2024} + POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" + ports: + - "5433:5432" # 5433 чтобы не конфликтовать с системным PostgreSQL + volumes: + - postgres_data:/var/lib/postgresql/data + - ./backend/db/init.sql:/docker-entrypoint-initdb.d/init.sql + networks: + - erv_network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U erv_user -d erv_platform"] + interval: 10s + timeout: 5s + retries: 5 + + # Redis для кеширования, сессий, rate limiting + redis: + image: redis:7-alpine + container_name: erv_redis + restart: unless-stopped + command: redis-server --requirepass ${REDIS_PASSWORD:-redis_secure_pass_2024} --appendonly yes + ports: + - "6380:6379" # 6380 чтобы не конфликтовать с системным Redis + volumes: + - redis_data:/data + networks: + - erv_network + healthcheck: + test: ["CMD", "redis-cli", "--raw", "incr", "ping"] + interval: 10s + timeout: 3s + retries: 5 + + # pgAdmin для управления PostgreSQL (опционально) + pgadmin: + image: dpage/pgadmin4:latest + container_name: erv_pgadmin + restart: unless-stopped + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@erv.local} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin} + PGADMIN_LISTEN_PORT: 80 + ports: + - "5050:80" + volumes: + - pgadmin_data:/var/lib/pgadmin + networks: + - erv_network + depends_on: + - postgres + + # FastAPI Backend + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: erv_backend + restart: unless-stopped + env_file: + - .env + environment: + # Database + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + POSTGRES_DB: erv_platform + POSTGRES_USER: erv_user + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-erv_secure_pass_2024} + + # Redis + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: ${REDIS_PASSWORD:-redis_secure_pass_2024} + + # RabbitMQ (внешний) + RABBITMQ_HOST: ${RABBITMQ_HOST:-185.197.75.249} + RABBITMQ_PORT: ${RABBITMQ_PORT:-5672} + RABBITMQ_USER: ${RABBITMQ_USER:-admin} + RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD:-tyejvtej} + + # API URLs + OCR_SERVICE_URL: ${OCR_SERVICE_URL:-http://147.45.146.17:8001} + + ports: + - "8100:8000" + volumes: + - ./backend/app:/app/app + - ./backend/logs:/app/logs + - uploads:/app/uploads + networks: + - erv_network + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + + # React Frontend + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: erv_frontend + restart: unless-stopped + ports: + - "5173:3000" + environment: + - VITE_API_URL=http://147.45.146.17:8100 + networks: + - erv_network + depends_on: + - backend + +networks: + erv_network: + driver: bridge + +volumes: + postgres_data: + driver: local + redis_data: + driver: local + pgadmin_data: + driver: local + uploads: + driver: local + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fb2c41d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,64 @@ +version: '3.8' + +services: + # React Frontend + frontend: + build: ./frontend + ports: + - "5173:3000" + environment: + - REACT_APP_API_URL=http://147.45.146.17:8100 + networks: + - erv-network + restart: unless-stopped + + # Python FastAPI Backend + backend: + build: ./backend + ports: + - "8100:8100" + environment: + - REDIS_URL=redis://redis:6379 + - POSTGRES_URL=postgresql://erv_user:erv_password@postgres:5432/erv_db + - RABBITMQ_URL=amqp://admin:tyejvtej@185.197.75.249:5672 + depends_on: + - redis + - postgres + networks: + - erv-network + restart: unless-stopped + + # Redis для кеширования + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - erv-network + restart: unless-stopped + + # PostgreSQL для логов и аналитики + postgres: + image: postgres:15-alpine + environment: + - POSTGRES_DB=erv_db + - POSTGRES_USER=erv_user + - POSTGRES_PASSWORD=erv_password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - erv-network + restart: unless-stopped + +volumes: + redis_data: + postgres_data: + +networks: + erv-network: + driver: bridge + diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..8e0020f --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,26 @@ +# React Frontend Dockerfile +FROM node:18-alpine + +# Устанавливаем рабочую директорию +WORKDIR /app + +# Копируем package.json +COPY package.json ./ + +# Устанавливаем зависимости +RUN npm install + +# Копируем исходный код +COPY . . + +# Собираем приложение +RUN npm run build + +# Устанавливаем serve для статических файлов +RUN npm install -g serve + +# Открываем порт +EXPOSE 3000 + +# Запускаем приложение +CMD ["serve", "-s", "dist", "-l", "3000"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..05d1228 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + ERV Insurance Platform + + +
+ + + + + diff --git a/frontend/package.json b/frontend/package.json index 2c9046f..c7dae92 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,8 @@ "build": "tsc && vite build", "preview": "vite preview", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "start": "serve -s dist -l 3000" }, "dependencies": { "react": "^18.3.1", @@ -23,7 +24,8 @@ "dayjs": "^1.11.13", "imask": "^7.6.1", "react-dropzone": "^14.3.5", - "socket.io-client": "^4.8.1" + "socket.io-client": "^4.8.1", + "serve": "^14.2.1" }, "devDependencies": { "@types/react": "^18.3.11", diff --git a/frontend/public/index.html b/frontend/public/index.html index e933a5d..05d1228 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -12,3 +12,4 @@ + diff --git a/frontend/src/App.css b/frontend/src/App.css index 3cbc7eb..ddb53c5 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -127,3 +127,4 @@ margin-top: auto; } + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e73f99c..69560f1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,124 +1,12 @@ -import { useState, useEffect } from 'react' +import ClaimForm from './pages/ClaimForm' import './App.css' -interface APIInfo { - platform?: string; - version?: string; - features?: string[]; - tech_stack?: any; -} - function App() { - const [apiInfo, setApiInfo] = useState(null) - const [health, setHealth] = useState(null) - const [loading, setLoading] = useState(true) - - useEffect(() => { - // Проверяем подключение к API - Promise.all([ - fetch('http://147.45.146.17:8100/api/v1/info').then(r => r.json()), - fetch('http://147.45.146.17:8100/health').then(r => r.json()) - ]) - .then(([info, healthData]) => { - setApiInfo(info) - setHealth(healthData) - setLoading(false) - }) - .catch(err => { - console.error('API Error:', err) - setLoading(false) - }) - }, []) - return ( -
-
-

🚀 ERV Insurance Platform

-

Python FastAPI + React TypeScript

-
- -
- {loading ? ( -
⏳ Подключение к API...
- ) : ( - <> -
-

📊 Информация о платформе

- {apiInfo ? ( - <> -

Платформа: {apiInfo.platform}

-

Версия: {apiInfo.version}

- -

✨ Возможности:

-
    - {apiInfo.features?.map((f, i) => ( -
  • {f}
  • - ))} -
- -

🛠️ Технологический стек:

-
{JSON.stringify(apiInfo.tech_stack, null, 2)}
- - ) : ( -

❌ Не удалось получить данные

- )} -
- -
-

🏥 Здоровье сервисов

- {health ? ( - <> -

- Статус: {health.status} -

- -

Сервисы:

-
    - {Object.entries(health.services || {}).map(([name, status]) => ( -
  • - - {status === 'ok' ? '✅' : '❌'} - - {name}: {String(status)} -
  • - ))} -
- - ) : ( -

❌ Health check недоступен

- )} -
- - - - )} -
- -
-

© 2025 ERV Insurance Platform | Powered by FastAPI + React

-
+
+
) } export default App - diff --git a/frontend/src/components/form/Step1Phone.tsx b/frontend/src/components/form/Step1Phone.tsx new file mode 100644 index 0000000..b0835df --- /dev/null +++ b/frontend/src/components/form/Step1Phone.tsx @@ -0,0 +1,199 @@ +import { useState } from 'react'; +import { Form, Input, Button, message, Space } from 'antd'; +import { PhoneOutlined, SafetyOutlined, FileProtectOutlined } from '@ant-design/icons'; + +interface Props { + formData: any; + updateFormData: (data: any) => void; + onNext: () => void; + isPhoneVerified: boolean; + setIsPhoneVerified: (verified: boolean) => void; +} + +export default function Step1Phone({ formData, updateFormData, onNext, isPhoneVerified, setIsPhoneVerified }: Props) { + const [form] = Form.useForm(); + const [codeSent, setCodeSent] = useState(false); + const [loading, setLoading] = useState(false); + const [verifyLoading, setVerifyLoading] = useState(false); + + const sendCode = async () => { + try { + const phone = form.getFieldValue('phone'); + if (!phone) { + message.error('Введите номер телефона'); + return; + } + + setLoading(true); + const response = await fetch('http://147.45.146.17:8100/api/v1/sms/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone }), + }); + + const result = await response.json(); + + if (response.ok) { + message.success('Код отправлен на ваш телефон'); + setCodeSent(true); + if (result.debug_code) { + message.info(`DEBUG: Код ${result.debug_code}`); + } + } else { + message.error(result.detail || 'Ошибка отправки кода'); + } + } catch (error) { + message.error('Ошибка соединения с сервером'); + } finally { + setLoading(false); + } + }; + + const verifyCode = async () => { + try { + const phone = form.getFieldValue('phone'); + const code = form.getFieldValue('smsCode'); + + if (!code) { + message.error('Введите код из SMS'); + return; + } + + setVerifyLoading(true); + const response = await fetch('http://147.45.146.17:8100/api/v1/sms/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone, code }), + }); + + const result = await response.json(); + + if (response.ok) { + message.success('Телефон подтвержден!'); + setIsPhoneVerified(true); + } else { + message.error(result.detail || 'Неверный код'); + } + } catch (error) { + message.error('Ошибка соединения с сервером'); + } finally { + setVerifyLoading(false); + } + }; + + const handleNext = async () => { + try { + const values = await form.validateFields(); + updateFormData(values); + onNext(); + } catch (error) { + message.error('Заполните все обязательные поля'); + } + }; + + return ( +
+ + } + placeholder="+79001234567" + disabled={isPhoneVerified} + maxLength={12} + /> + + + {!isPhoneVerified && ( + <> + + + + + {codeSent && ( + + + } + placeholder="123456" + maxLength={6} + style={{ width: '70%' }} + /> + + + + )} + + )} + + {isPhoneVerified && ( + <> + + + + + + + + + + } placeholder="123456789" /> + + + + + + + + + + + )} +
+ ); +} + diff --git a/frontend/src/components/form/Step2Details.tsx b/frontend/src/components/form/Step2Details.tsx new file mode 100644 index 0000000..a71794d --- /dev/null +++ b/frontend/src/components/form/Step2Details.tsx @@ -0,0 +1,122 @@ +import { Form, Input, DatePicker, Select, Button, Upload, message } from 'antd'; +import { UploadOutlined } from '@ant-design/icons'; +import type { UploadFile } from 'antd/es/upload/interface'; +import { useState } from 'react'; + +const { TextArea } = Input; +const { Option } = Select; + +interface Props { + formData: any; + updateFormData: (data: any) => void; + onNext: () => void; + onPrev: () => void; +} + +export default function Step2Details({ formData, updateFormData, onNext, onPrev }: Props) { + const [form] = Form.useForm(); + const [fileList, setFileList] = useState([]); + + const handleNext = async () => { + try { + const values = await form.validateFields(); + updateFormData({ + ...values, + incidentDate: values.incidentDate?.format('YYYY-MM-DD'), + uploadedFiles: fileList.map(f => f.uid), + }); + onNext(); + } catch (error) { + message.error('Заполните все обязательные поля'); + } + }; + + const uploadProps = { + fileList, + beforeUpload: (file: File) => { + const isImage = file.type.startsWith('image/'); + const isPDF = file.type === 'application/pdf'; + if (!isImage && !isPDF) { + message.error('Можно загружать только изображения и PDF'); + return false; + } + const isLt10M = file.size / 1024 / 1024 < 10; + if (!isLt10M) { + message.error('Файл должен быть меньше 10MB'); + return false; + } + + setFileList([...fileList, { + uid: Math.random().toString(), + name: file.name, + status: 'done', + url: URL.createObjectURL(file), + } as UploadFile]); + + return false; // Отключаем автозагрузку + }, + onRemove: (file: UploadFile) => { + setFileList(fileList.filter(f => f.uid !== file.uid)); + }, + }; + + return ( +
+ + + + + + + + + +