🚀 MVP: FastAPI + React форма с SMS верификацией

 Инфраструктура: PostgreSQL, Redis, RabbitMQ, S3
 Backend: SMS сервис + API endpoints
 Frontend: React форма (3 шага) + SMS верификация
This commit is contained in:
AI Assistant
2025-10-24 16:19:58 +03:00
parent 8af23e90fa
commit 0f82eef08d
42 changed files with 2902 additions and 241 deletions

1
.gitignore vendored
View File

@@ -54,3 +54,4 @@ htmlcov/
*.tmp *.tmp
*.bak *.bak

41
LINKS.md Normal file
View File

@@ -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
```

View File

@@ -584,3 +584,4 @@ server {
Сейчас создам всю базовую структуру и запущу оба приложения (FastAPI + React). Сейчас создам всю базовую структуру и запущу оба приложения (FastAPI + React).
**Начинаю прямо сейчас!** 🚀 **Начинаю прямо сейчас!** 🚀

View File

@@ -181,3 +181,4 @@ git push -u origin main
**Удачи!** 🚀 **Удачи!** 🚀

View File

@@ -170,3 +170,4 @@ git push origin main
**Автор**: AI Assistant + Фёдор **Автор**: AI Assistant + Фёдор
**Дата**: 24.10.2025 **Дата**: 24.10.2025

173
START_HERE.md Normal file
View File

@@ -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
```
---
**ЗАПУСКАЙ И ПИШИ ЧТО ПОЛУЧИЛОСЬ!** 🚀

21
backend/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -0,0 +1,4 @@
"""
API Routes
"""

51
backend/app/api/claims.py Normal file
View File

@@ -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": "Заявка в обработке"
}

64
backend/app/api/models.py Normal file
View File

@@ -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

53
backend/app/api/sms.py Normal file
View File

@@ -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="Неверный код или код истек"
)

View File

@@ -3,10 +3,13 @@
""" """
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from functools import lru_cache from functools import lru_cache
from typing import List
class Settings(BaseSettings): class Settings(BaseSettings):
# App # ============================================
# APPLICATION
# ============================================
app_name: str = "ERV Insurance Platform" app_name: str = "ERV Insurance Platform"
app_env: str = "development" app_env: str = "development"
debug: bool = True debug: bool = True
@@ -16,47 +19,144 @@ class Settings(BaseSettings):
backend_url: str = "http://localhost:8100" backend_url: str = "http://localhost:8100"
frontend_url: str = "http://localhost:5173" frontend_url: str = "http://localhost:5173"
# PostgreSQL # ============================================
# DATABASE (PostgreSQL)
# ============================================
postgres_host: str = "147.45.189.234" postgres_host: str = "147.45.189.234"
postgres_port: int = 5432 postgres_port: int = 5432
postgres_db: str = "default_db" postgres_db: str = "default_db"
postgres_user: str = "gen_user" postgres_user: str = "gen_user"
postgres_password: str = "2~~9_^kVsU?2\\S" 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_host: str = "localhost"
redis_port: int = 6379 redis_port: int = 6379
redis_password: str = "CRM_Redis_Pass_2025_Secure!" redis_password: str = "CRM_Redis_Pass_2025_Secure!"
redis_db: int = 0 redis_db: int = 0
redis_prefix: str = "erv:" 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_host: str = "185.197.75.249"
rabbitmq_port: int = 5672 rabbitmq_port: int = 5672
rabbitmq_user: str = "admin" rabbitmq_user: str = "admin"
rabbitmq_password: str = "tyejvtej" rabbitmq_password: str = "tyejvtej"
rabbitmq_vhost: str = "/" rabbitmq_vhost: str = "/"
# OCR Service @property
ocr_api_url: str = "http://147.45.146.17:8001" 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_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" openrouter_model: str = "google/gemini-2.0-flash-001"
# ============================================
# FLIGHT APIs
# ============================================
# FlightAware # FlightAware
flightaware_api_key: str = "Puz0cdxAHzAEqMRZwtdeqBUSm9naJfwK" 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
cors_origins: list = [ # ============================================
"http://localhost:5173", cors_origins: str = "http://localhost:5173,http://147.45.146.17:5173,https://erv-claims.clientright.ru,http://crm.clientright.ru"
"http://147.45.146.17:5173",
"https://erv-claims.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: class Config:
env_file = "../.env" env_file = "../.env"
case_sensitive = False case_sensitive = False
extra = "ignore" # Игнорируем лишние поля из .env
@lru_cache() @lru_cache()
@@ -66,3 +166,4 @@ def get_settings() -> Settings:
settings = get_settings() settings = get_settings()

View File

@@ -3,33 +3,84 @@ ERV Insurance Platform - FastAPI Backend
""" """
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from contextlib import asynccontextmanager
from app.config import settings import logging
import redis
import asyncpg 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 приложение # Создаём FastAPI приложение
app = FastAPI( app = FastAPI(
title="ERV Insurance Platform API", title="ERV Insurance Platform API",
description="API для обработки страховых обращений с OCR, AI и интеграциями", description="API для обработки страховых обращений",
version="1.0.0", version="1.0.0",
docs_url="/docs", lifespan=lifespan
redoc_url="/redoc"
) )
# CORS middleware # CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins, allow_origins=settings.cors_origins_list,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# API Routes
app.include_router(sms.router)
app.include_router(claims.router)
# ============================================
# HEALTH CHECKS
# ============================================
@app.get("/") @app.get("/")
async def root(): async def root():
@@ -37,140 +88,111 @@ async def root():
return { return {
"message": "🚀 ERV Insurance Platform API", "message": "🚀 ERV Insurance Platform API",
"version": "1.0.0", "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") @app.get("/health")
async def health_check(): async def health():
"""Проверка здоровья сервисов""" """Health check - проверка всех сервисов"""
health_status = { health_status = {
"api": "ok", "status": "ok",
"redis": "checking", "message": "API работает!",
"postgres": "checking", "services": {}
"ocr": "checking"
} }
# Проверка 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 # Проверка PostgreSQL
try: try:
conn = await asyncpg.connect( pg_healthy = await db.health_check()
host=settings.postgres_host, health_status["services"]["postgresql"] = {
port=settings.postgres_port, "status": "✅ healthy" if pg_healthy else "❌ unhealthy",
database=settings.postgres_db, "connected": pg_healthy
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
} }
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") @app.get("/api/v1/test")
async def test_endpoint(): async def test():
"""Тестовый endpoint""" """Тестовый endpoint"""
return { return {
"message": "✅ API работает!", "success": True,
"env": settings.app_env, "message": "✅ Backend API работает!",
"debug": settings.debug,
"services": { "services": {
"redis": f"{settings.redis_host}:{settings.redis_port}", "redis": "localhost:6379",
"postgres": f"{settings.postgres_host}:{settings.postgres_port}", "postgres": "147.45.189.234:5432",
"rabbitmq": f"{settings.rabbitmq_host}:{settings.rabbitmq_port}", "ocr": "147.45.146.17:8001",
"ocr": settings.ocr_api_url "rabbitmq": "185.197.75.249:5672"
} }
} }
@app.get("/api/v1/info") @app.get("/api/v1/info")
async def get_info(): async def info():
"""Информация о платформе""" """Информация о платформе"""
return { return {
"platform": "ERV Insurance Claims", "platform": "ERV Insurance Claims",
"version": "1.0.0", "version": "1.0.0",
"features": [
"OCR документов (паспорт, билеты)",
"AI автозаполнение (Gemini Vision)",
"Проверка рейсов (FlightAware)",
"СБП выплаты",
"Интеграция с CRM"
],
"tech_stack": { "tech_stack": {
"backend": "Python FastAPI", "backend": "Python FastAPI",
"frontend": "React TypeScript", "frontend": "React TypeScript",
"database": "PostgreSQL + MySQL", "database": "PostgreSQL + MySQL",
"cache": "Redis", "cache": "Redis",
"queue": "RabbitMQ", "queue": "RabbitMQ",
"storage": "S3 Timeweb", "storage": "S3 Timeweb"
"ocr": "Internal Service", },
"ai": "OpenRouter Gemini 2.0" "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__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run( uvicorn.run(app, host="0.0.0.0", port=8100)
"main:app",
host="0.0.0.0",
port=8100,
reload=True
)

View File

@@ -0,0 +1,4 @@
"""
ERV Platform Services
"""

View File

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

View File

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

View File

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

View File

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

306
backend/db/init.sql Normal file
View File

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

137
docker-compose.full.yml Normal file
View File

@@ -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

64
docker-compose.yml Normal file
View File

@@ -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

26
frontend/Dockerfile Normal file
View File

@@ -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"]

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ERV Insurance Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -9,7 +9,8 @@
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "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": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",
@@ -23,7 +24,8 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"imask": "^7.6.1", "imask": "^7.6.1",
"react-dropzone": "^14.3.5", "react-dropzone": "^14.3.5",
"socket.io-client": "^4.8.1" "socket.io-client": "^4.8.1",
"serve": "^14.2.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.11", "@types/react": "^18.3.11",

View File

@@ -12,3 +12,4 @@
</body> </body>
</html> </html>

View File

@@ -127,3 +127,4 @@
margin-top: auto; margin-top: auto;
} }

View File

@@ -1,124 +1,12 @@
import { useState, useEffect } from 'react' import ClaimForm from './pages/ClaimForm'
import './App.css' import './App.css'
interface APIInfo {
platform?: string;
version?: string;
features?: string[];
tech_stack?: any;
}
function App() { function App() {
const [apiInfo, setApiInfo] = useState<APIInfo | null>(null)
const [health, setHealth] = useState<any>(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 ( return (
<div className="app"> <div className="App">
<header className="app-header"> <ClaimForm />
<h1>🚀 ERV Insurance Platform</h1>
<p>Python FastAPI + React TypeScript</p>
</header>
<main className="app-main">
{loading ? (
<div className="loading"> Подключение к API...</div>
) : (
<>
<div className="card">
<h2>📊 Информация о платформе</h2>
{apiInfo ? (
<>
<p><strong>Платформа:</strong> {apiInfo.platform}</p>
<p><strong>Версия:</strong> {apiInfo.version}</p>
<h3> Возможности:</h3>
<ul>
{apiInfo.features?.map((f, i) => (
<li key={i}>{f}</li>
))}
</ul>
<h3>🛠 Технологический стек:</h3>
<pre>{JSON.stringify(apiInfo.tech_stack, null, 2)}</pre>
</>
) : (
<p className="error"> Не удалось получить данные</p>
)}
</div>
<div className="card">
<h2>🏥 Здоровье сервисов</h2>
{health ? (
<>
<p className={health.status === 'healthy' ? 'success' : 'warning'}>
Статус: <strong>{health.status}</strong>
</p>
<h3>Сервисы:</h3>
<ul className="services">
{Object.entries(health.services || {}).map(([name, status]) => (
<li key={name}>
<span className={status === 'ok' ? 'status-ok' : 'status-error'}>
{status === 'ok' ? '✅' : '❌'}
</span>
<strong>{name}:</strong> {String(status)}
</li>
))}
</ul>
</>
) : (
<p className="error"> Health check недоступен</p>
)}
</div>
<div className="card">
<h2>🔗 Полезные ссылки</h2>
<ul>
<li>
<a href="http://147.45.146.17:8100/docs" target="_blank">
📚 API Документация (Swagger UI)
</a>
</li>
<li>
<a href="http://147.45.146.17:8100/health" target="_blank">
🏥 Health Check
</a>
</li>
<li>
<a href="http://147.45.146.17:3002" target="_blank">
🐙 Gitea (Git репозиторий)
</a>
</li>
</ul>
</div>
</>
)}
</main>
<footer className="app-footer">
<p>© 2025 ERV Insurance Platform | Powered by FastAPI + React</p>
</footer>
</div> </div>
) )
} }
export default App export default App

View File

@@ -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 (
<Form
form={form}
layout="vertical"
initialValues={formData}
style={{ marginTop: 24 }}
>
<Form.Item
label="Номер телефона"
name="phone"
rules={[
{ required: true, message: 'Введите номер телефона' },
{ pattern: /^\+7\d{10}$/, message: 'Формат: +79001234567' }
]}
>
<Input
prefix={<PhoneOutlined />}
placeholder="+79001234567"
disabled={isPhoneVerified}
maxLength={12}
/>
</Form.Item>
{!isPhoneVerified && (
<>
<Form.Item>
<Button
type="primary"
onClick={sendCode}
loading={loading}
disabled={codeSent}
>
{codeSent ? 'Код отправлен' : 'Отправить код'}
</Button>
</Form.Item>
{codeSent && (
<Form.Item
label="Код из SMS"
name="smsCode"
rules={[{ required: true, message: 'Введите код' }, { len: 6, message: '6 цифр' }]}
>
<Space.Compact style={{ width: '100%' }}>
<Input
prefix={<SafetyOutlined />}
placeholder="123456"
maxLength={6}
style={{ width: '70%' }}
/>
<Button
type="primary"
onClick={verifyCode}
loading={verifyLoading}
style={{ width: '30%' }}
>
Проверить
</Button>
</Space.Compact>
</Form.Item>
)}
</>
)}
{isPhoneVerified && (
<>
<Form.Item
label="Email (необязательно)"
name="email"
rules={[{ type: 'email', message: 'Неверный формат email' }]}
>
<Input placeholder="example@mail.ru" />
</Form.Item>
<Form.Item
label="ИНН (необязательно)"
name="inn"
>
<Input placeholder="1234567890" maxLength={12} />
</Form.Item>
<Form.Item
label="Номер полиса"
name="policyNumber"
rules={[{ required: true, message: 'Введите номер полиса' }]}
>
<Input prefix={<FileProtectOutlined />} placeholder="123456789" />
</Form.Item>
<Form.Item
label="Серия полиса (необязательно)"
name="policySeries"
>
<Input placeholder="AB" />
</Form.Item>
<Form.Item>
<Button type="primary" onClick={handleNext} size="large" block>
Далее
</Button>
</Form.Item>
</>
)}
</Form>
);
}

View File

@@ -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<UploadFile[]>([]);
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 (
<Form
form={form}
layout="vertical"
initialValues={formData}
style={{ marginTop: 24 }}
>
<Form.Item
label="Дата происшествия"
name="incidentDate"
>
<DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" />
</Form.Item>
<Form.Item
label="Тип транспорта"
name="transportType"
>
<Select placeholder="Выберите тип транспорта">
<Option value="air">Авиа</Option>
<Option value="train">Поезд</Option>
<Option value="bus">Автобус</Option>
<Option value="ship">Водный транспорт</Option>
<Option value="other">Другое</Option>
</Select>
</Form.Item>
<Form.Item
label="Описание происшествия"
name="incidentDescription"
>
<TextArea
rows={4}
placeholder="Опишите что произошло..."
maxLength={1000}
showCount
/>
</Form.Item>
<Form.Item label="Документы (билеты, справки, чеки)">
<Upload {...uploadProps} listType="picture">
<Button icon={<UploadOutlined />}>Загрузить файлы</Button>
</Upload>
<div style={{ marginTop: 8, color: '#999', fontSize: 12 }}>
Максимум 10 MB на файл. Форматы: JPG, PNG, PDF, HEIC
</div>
</Form.Item>
<Form.Item>
<div style={{ display: 'flex', gap: 8 }}>
<Button onClick={onPrev}>Назад</Button>
<Button type="primary" onClick={handleNext} style={{ flex: 1 }}>
Далее
</Button>
</div>
</Form.Item>
</Form>
);
}

View File

@@ -0,0 +1,131 @@
import { Form, Input, Radio, Button, Select, message } from 'antd';
import { BankOutlined, CreditCardOutlined, QrcodeOutlined } from '@ant-design/icons';
import { useState } from 'react';
const { Option } = Select;
interface Props {
formData: any;
updateFormData: (data: any) => void;
onPrev: () => void;
onSubmit: () => void;
}
export default function Step3Payment({ formData, updateFormData, onPrev, onSubmit }: Props) {
const [form] = Form.useForm();
const [paymentMethod, setPaymentMethod] = useState(formData.paymentMethod || 'sbp');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
updateFormData(values);
setSubmitting(true);
await onSubmit();
} catch (error) {
message.error('Заполните все обязательные поля');
} finally {
setSubmitting(false);
}
};
return (
<Form
form={form}
layout="vertical"
initialValues={formData}
style={{ marginTop: 24 }}
>
<Form.Item
label="Способ выплаты"
name="paymentMethod"
rules={[{ required: true, message: 'Выберите способ выплаты' }]}
>
<Radio.Group onChange={(e) => setPaymentMethod(e.target.value)}>
<Radio.Button value="sbp">
<QrcodeOutlined /> СБП (Быстрые платежи)
</Radio.Button>
<Radio.Button value="card">
<CreditCardOutlined /> Карта
</Radio.Button>
<Radio.Button value="bank_transfer">
<BankOutlined /> Банковский счет
</Radio.Button>
</Radio.Group>
</Form.Item>
{paymentMethod === 'sbp' && (
<Form.Item
label="Банк для СБП"
name="bankName"
rules={[{ required: true, message: 'Выберите банк' }]}
>
<Select placeholder="Выберите ваш банк">
<Option value="sberbank">Сбербанк</Option>
<Option value="tinkoff">Тинькофф</Option>
<Option value="vtb">ВТБ</Option>
<Option value="alfabank">Альфа-Банк</Option>
<Option value="raiffeisen">Райффайзенбанк</Option>
<Option value="other">Другой</Option>
</Select>
</Form.Item>
)}
{paymentMethod === 'card' && (
<Form.Item
label="Номер карты"
name="cardNumber"
rules={[
{ required: true, message: 'Введите номер карты' },
{ pattern: /^\d{16}$/, message: '16 цифр без пробелов' }
]}
>
<Input
prefix={<CreditCardOutlined />}
placeholder="1234567890123456"
maxLength={16}
/>
</Form.Item>
)}
{paymentMethod === 'bank_transfer' && (
<>
<Form.Item
label="Название банка"
name="bankName"
rules={[{ required: true, message: 'Введите название банка' }]}
>
<Input prefix={<BankOutlined />} placeholder="Сбербанк" />
</Form.Item>
<Form.Item
label="Номер счета"
name="accountNumber"
rules={[
{ required: true, message: 'Введите номер счета' },
{ pattern: /^\d{20}$/, message: '20 цифр' }
]}
>
<Input placeholder="12345678901234567890" maxLength={20} />
</Form.Item>
</>
)}
<Form.Item>
<div style={{ display: 'flex', gap: 8, marginTop: 32 }}>
<Button onClick={onPrev}>Назад</Button>
<Button
type="primary"
onClick={handleSubmit}
loading={submitting}
style={{ flex: 1 }}
size="large"
>
Отправить заявку
</Button>
</div>
</Form.Item>
</Form>
);
}

View File

@@ -15,3 +15,4 @@ body {
min-height: 100vh; min-height: 100vh;
} }

View File

@@ -9,3 +9,4 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</React.StrictMode>, </React.StrictMode>,
) )

View File

@@ -0,0 +1,51 @@
.claim-form-container {
min-height: 100vh;
padding: 40px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
justify-content: center;
align-items: center;
}
.claim-form-card {
max-width: 800px;
width: 100%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
border-radius: 12px;
}
.claim-form-card .ant-card-head {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px 12px 0 0;
}
.claim-form-card .ant-card-head-title {
color: white;
font-size: 24px;
font-weight: 600;
}
.steps {
margin-bottom: 32px;
}
.steps-content {
min-height: 400px;
padding: 20px;
}
@media (max-width: 768px) {
.claim-form-container {
padding: 20px 10px;
}
.claim-form-card {
margin: 0;
}
.steps-content {
padding: 10px;
}
}

View File

@@ -0,0 +1,147 @@
import { useState } from 'react';
import { Steps, Card, message } from 'antd';
import Step1Phone from '../components/form/Step1Phone';
import Step2Details from '../components/form/Step2Details';
import Step3Payment from '../components/form/Step3Payment';
import './ClaimForm.css';
const { Step } = Steps;
interface FormData {
// Шаг 1
phone: string;
email?: string;
inn?: string;
policyNumber: string;
policySeries?: string;
// Шаг 2
incidentDate?: string;
incidentDescription?: string;
transportType?: string;
uploadedFiles?: string[];
// Шаг 3
paymentMethod: string;
bankName?: string;
cardNumber?: string;
accountNumber?: string;
}
export default function ClaimForm() {
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState<FormData>({
phone: '',
policyNumber: '',
paymentMethod: 'sbp',
});
const [isPhoneVerified, setIsPhoneVerified] = useState(false);
const updateFormData = (data: Partial<FormData>) => {
setFormData({ ...formData, ...data });
};
const nextStep = () => {
setCurrentStep(currentStep + 1);
};
const prevStep = () => {
setCurrentStep(currentStep - 1);
};
const handleSubmit = async () => {
try {
const response = await fetch('http://147.45.146.17:8100/api/v1/claims/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
phone: formData.phone,
email: formData.email,
inn: formData.inn,
policy_number: formData.policyNumber,
policy_series: formData.policySeries,
incident_date: formData.incidentDate,
incident_description: formData.incidentDescription,
transport_type: formData.transportType,
payment_method: formData.paymentMethod,
bank_name: formData.bankName,
card_number: formData.cardNumber,
account_number: formData.accountNumber,
uploaded_files: formData.uploadedFiles || [],
}),
});
const result = await response.json();
if (result.success) {
message.success(`Заявка ${result.claim_number} успешно создана!`);
// Сброс формы
setFormData({
phone: '',
policyNumber: '',
paymentMethod: 'sbp',
});
setCurrentStep(0);
setIsPhoneVerified(false);
} else {
message.error('Ошибка при создании заявки');
}
} catch (error) {
message.error('Ошибка соединения с сервером');
console.error(error);
}
};
const steps = [
{
title: 'Телефон и полис',
content: (
<Step1Phone
formData={formData}
updateFormData={updateFormData}
onNext={nextStep}
isPhoneVerified={isPhoneVerified}
setIsPhoneVerified={setIsPhoneVerified}
/>
),
},
{
title: 'Детали происшествия',
content: (
<Step2Details
formData={formData}
updateFormData={updateFormData}
onNext={nextStep}
onPrev={prevStep}
/>
),
},
{
title: 'Способ выплаты',
content: (
<Step3Payment
formData={formData}
updateFormData={updateFormData}
onPrev={prevStep}
onSubmit={handleSubmit}
/>
),
},
];
return (
<div className="claim-form-container">
<Card title="Подать заявку на выплату" className="claim-form-card">
<Steps current={currentStep} className="steps">
{steps.map((item) => (
<Step key={item.title} title={item.title} />
))}
</Steps>
<div className="steps-content">{steps[currentStep].content}</div>
</Card>
</div>
);
}

View File

@@ -20,3 +20,4 @@
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
} }

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -15,3 +15,4 @@ export default defineConfig({
} }
}) })

164
index.html Normal file
View File

@@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ERV Insurance Platform - MVP</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container { max-width: 1200px; margin: 0 auto; }
.header {
text-align: center;
color: white;
margin-bottom: 40px;
}
.header h1 {
font-size: 3em;
margin-bottom: 10px;
}
.card {
background: white;
border-radius: 12px;
padding: 30px;
margin-bottom: 20px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
}
.status {
display: inline-block;
padding: 8px 16px;
border-radius: 20px;
font-weight: bold;
margin: 5px;
}
.status.success { background: #10b981; color: white; }
.status.warning { background: #f59e0b; color: white; }
.status.error { background: #ef4444; color: white; }
.loading { text-align: center; padding: 40px; color: #666; }
.links { display: flex; gap: 10px; flex-wrap: wrap; }
.btn {
display: inline-block;
padding: 12px 24px;
background: #667eea;
color: white;
text-decoration: none;
border-radius: 6px;
transition: all 0.3s;
}
.btn:hover { background: #5568d3; transform: translateY(-2px); }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; }
pre {
background: #f3f4f6;
padding: 15px;
border-radius: 6px;
overflow-x: auto;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚀 ERV Insurance Platform</h1>
<p style="font-size: 1.2em;">Python FastAPI + React TypeScript - MVP</p>
</div>
<div class="card">
<h2>📊 Статус системы</h2>
<div id="status-container" class="loading">⏳ Проверяю сервисы...</div>
</div>
<div class="grid">
<div class="card">
<h3>🔗 API Endpoints</h3>
<div class="links">
<a href="http://147.45.146.17:8100/docs" target="_blank" class="btn">📚 Swagger UI</a>
<a href="http://147.45.146.17:8100/health" target="_blank" class="btn">🏥 Health Check</a>
<a href="http://147.45.146.17:8100/api/v1/info" target="_blank" class="btn"> Info</a>
</div>
</div>
<div class="card">
<h3>🛠️ Технологии</h3>
<div id="tech-stack"></div>
</div>
<div class="card">
<h3>✨ Возможности</h3>
<div id="features"></div>
</div>
</div>
<div class="card">
<h3>📋 Детальная информация API</h3>
<pre id="api-details">Загрузка...</pre>
</div>
</div>
<script>
async function checkAPI() {
const statusContainer = document.getElementById('status-container');
const techStack = document.getElementById('tech-stack');
const features = document.getElementById('features');
const apiDetails = document.getElementById('api-details');
try {
// Проверяем API
const [healthRes, infoRes, testRes] = await Promise.all([
fetch('http://147.45.146.17:8100/health'),
fetch('http://147.45.146.17:8100/api/v1/info'),
fetch('http://147.45.146.17:8100/api/v1/test')
]);
const health = await healthRes.json();
const info = await infoRes.json();
const test = await testRes.json();
// Статус
statusContainer.innerHTML = `
<div style="margin: 20px 0;">
<span class="status success">✅ Backend API работает!</span>
<span class="status success">✅ Health: ${health.status}</span>
<span class="status success">✅ Version: ${info.version}</span>
</div>
`;
// Технологии
const stack = info.tech_stack || {};
techStack.innerHTML = Object.entries(stack)
.map(([key, value]) => `<div><strong>${key}:</strong> ${value}</div>`)
.join('');
// Возможности
features.innerHTML = (info.features || [])
.map(f => `<div>✓ ${f}</div>`)
.join('');
// Детали
apiDetails.textContent = JSON.stringify({ health, info, test }, null, 2);
} catch (error) {
statusContainer.innerHTML = `
<span class="status error">❌ Ошибка подключения к API</span>
<p style="margin-top: 10px; color: #666;">
Проверьте что FastAPI запущен на порту 8100
</p>
`;
apiDetails.textContent = `Ошибка: ${error.message}`;
}
}
// Запускаем проверку
checkAPI();
// Обновляем каждые 10 секунд
setInterval(checkAPI, 10000);
</script>
</body>
</html>

118
links.html Normal file
View File

@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ERV Platform - Ссылки</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
background: #f5f5f5;
}
h1 {
color: #333;
}
.link-box {
background: white;
padding: 20px;
margin: 20px 0;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.link-box h2 {
color: #667eea;
margin-top: 0;
}
.link-box a {
display: block;
font-size: 18px;
color: #667eea;
text-decoration: none;
padding: 10px;
background: #f0f0f0;
border-radius: 4px;
margin: 10px 0;
}
.link-box a:hover {
background: #e0e0e0;
}
.description {
color: #666;
font-size: 14px;
margin-top: 5px;
}
</style>
</head>
<body>
<h1>🚀 ERV Insurance Platform - Ссылки для доступа</h1>
<div class="link-box">
<h2>🎨 Frontend (React приложение)</h2>
<a href="http://147.45.146.17:5173/" target="_blank">
http://147.45.146.17:5173/
</a>
<div class="description">
Красивая страница с информацией о платформе, статусом сервисов
</div>
</div>
<div class="link-box">
<h2>🔌 Backend API</h2>
<a href="http://147.45.146.17:8100/" target="_blank">
http://147.45.146.17:8100/
</a>
<div class="description">
JSON API endpoint (главная страница)
</div>
</div>
<div class="link-box">
<h2>📚 API Документация (Swagger UI)</h2>
<a href="http://147.45.146.17:8100/docs" target="_blank">
http://147.45.146.17:8100/docs
</a>
<div class="description">
Интерактивная документация API - можно тестировать прямо в браузере!
</div>
</div>
<div class="link-box">
<h2>🏥 Health Check</h2>
<a href="http://147.45.146.17:8100/health" target="_blank">
http://147.45.146.17:8100/health
</a>
<div class="description">
Проверка работоспособности всех сервисов (Redis, PostgreSQL, OCR)
</div>
</div>
<div class="link-box">
<h2>🧪 Test Endpoint</h2>
<a href="http://147.45.146.17:8100/api/v1/test" target="_blank">
http://147.45.146.17:8100/api/v1/test
</a>
<div class="description">
Тестовый endpoint с информацией о подключенных сервисах
</div>
</div>
<div class="link-box">
<h2>🐙 Gitea (Git репозиторий)</h2>
<a href="http://147.45.146.17:3002/negodiy/erv-platform" target="_blank">
http://147.45.146.17:3002/negodiy/erv-platform
</a>
<div class="description">
Git репозиторий с кодом (логин: negodiy)
</div>
</div>
<hr>
<p><strong>⚠️ Важно:</strong> Сначала нужно запустить оба сервера!</p>
<p>См. файл <code>START_HERE.md</code> с инструкциями</p>
</body>
</html>

6
start_backend.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
cd /var/www/fastuser/data/www/crm.clientright.ru/erv_platform/backend
source venv/bin/activate
uvicorn app.main:app --host 0.0.0.0 --port 8100 --reload

5
start_frontend.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
cd /var/www/fastuser/data/www/crm.clientright.ru/erv_platform/frontend
npm run dev -- --host 0.0.0.0

96
ЗАПУСК.md Normal file
View File

@@ -0,0 +1,96 @@
# ⚡ ПРОСТОЙ ЗАПУСК MVP
## 🎯 Фёдор, делай так:
---
## СПОСОБ 1: Через скрипты (ПРОЩЕ!)
### Терминал 1 - Backend:
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/erv_platform
./start_backend.sh
```
### Терминал 2 - Frontend:
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/erv_platform
./start_frontend.sh
```
---
## СПОСОБ 2: Вручную (если скрипты не работают)
### Терминал 1 - Backend:
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/erv_platform/backend
source venv/bin/activate
uvicorn app.main:app --host 0.0.0.0 --port 8100 --reload
```
### Терминал 2 - Frontend:
```bash
cd /var/www/fastuser/data/www/crm.clientright.ru/erv_platform/frontend
npm install
npm run dev -- --host 0.0.0.0
```
---
## 🌐 ССЫЛКИ (открывай после запуска):
### ✅ Frontend (красивая страница):
http://147.45.146.17:5173/
### ✅ API Документация (Swagger UI):
http://147.45.146.17:8100/docs
### ✅ Health Check (проверка сервисов):
http://147.45.146.17:8100/health
### ✅ Test API:
http://147.45.146.17:8100/api/v1/test
### ✅ Git репозиторий:
http://147.45.146.17:3002/negodiy/erv-platform
---
## ✅ Что увидишь на http://147.45.146.17:5173/
Красивую страницу с:
- 📊 Информация о платформе
- 🏥 Статус всех сервисов (Redis ✅, PostgreSQL ✅, OCR ✅)
- ✨ Список возможностей
- 🛠️ Технологический стек
- 🔗 Ссылки на API docs
---
## 🐛 Проблемы?
### Backend не запустился?
Смотри логи:
```bash
tail -f /var/www/fastuser/data/www/crm.clientright.ru/erv_platform/backend/uvicorn.log
```
### Frontend не запустился?
Проверь Node.js:
```bash
node --version # Должно быть >= 18
```
---
## 📞 Скажи мне:
1. ✅ "Запустилось!" - и я продолжу делать полную функциональность
2. ❌ "Ошибка: ..." - покажи текст ошибки и я исправлю
**ЗАПУСКАЙ!** 🚀