Добавлено логирование для отладки черновиков
- Добавлены логи в frontend (ClaimForm.tsx) для отслеживания unified_id и запросов к API - Добавлены логи в backend (claims.py) для отладки SQL запросов - Создан лог сессии с описанием проблемы и текущего состояния - Проблема: API возвращает 0 черновиков, хотя в БД есть данные
This commit is contained in:
@@ -550,3 +550,4 @@ Last commit: c049ed6 - "fix: Добавлены n8n webhook URLs в docker-compo
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -114,3 +114,4 @@ tail -f /var/www/fastuser/data/www/crm.clientright.ru/logs/upload_documents.log
|
|||||||
|
|
||||||
Подробная документация: `DOCUMENT_ATTACH_API.md`
|
Подробная документация: `DOCUMENT_ATTACH_API.md`
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -1,6 +1,6 @@
|
|||||||
# 🚀 ERV Insurance Platform
|
# 🚀 Ticket Form Intake Platform
|
||||||
|
|
||||||
**Современная платформа для страховых обращений**
|
**Платформа цифровой приёмки обращений для other.clientright.ru**
|
||||||
|
|
||||||
- **Backend**: Python FastAPI (async)
|
- **Backend**: Python FastAPI (async)
|
||||||
- **Frontend**: React 18 + TypeScript
|
- **Frontend**: React 18 + TypeScript
|
||||||
@@ -18,13 +18,13 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
Frontend (форма):
|
Frontend (форма):
|
||||||
http://147.45.146.17:5173/
|
http://147.45.146.17:5175/
|
||||||
|
|
||||||
Backend API:
|
Backend API:
|
||||||
http://147.45.146.17:8100/
|
http://147.45.146.17:8200/
|
||||||
|
|
||||||
API Документация (Swagger UI):
|
API Документация (Swagger UI):
|
||||||
http://147.45.146.17:8100/docs ← Интерактивная!
|
http://147.45.146.17:8200/docs ← Интерактивная!
|
||||||
|
|
||||||
Gitea (Git репозиторий):
|
Gitea (Git репозиторий):
|
||||||
http://147.45.146.17:3002/
|
http://147.45.146.17:3002/
|
||||||
@@ -47,7 +47,7 @@ source venv/bin/activate
|
|||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# Запускаем сервер
|
# Запускаем сервер
|
||||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8100
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8200
|
||||||
```
|
```
|
||||||
|
|
||||||
### **Frontend (React):**
|
### **Frontend (React):**
|
||||||
@@ -59,7 +59,7 @@ cd frontend
|
|||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Запускаем dev сервер
|
# Запускаем dev сервер
|
||||||
npm run dev -- --host 0.0.0.0 --port 5173
|
npm run dev -- --host 0.0.0.0 --port 5175
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -69,7 +69,7 @@ npm run dev -- --host 0.0.0.0 --port 5173
|
|||||||
### **Поток данных:**
|
### **Поток данных:**
|
||||||
|
|
||||||
```
|
```
|
||||||
React (5173) → FastAPI (8100) → [Redis, RabbitMQ, PostgreSQL]
|
React (5175) → FastAPI (8200) → [Redis, RabbitMQ, PostgreSQL]
|
||||||
↓
|
↓
|
||||||
OCR Service (8001)
|
OCR Service (8001)
|
||||||
OpenRouter AI
|
OpenRouter AI
|
||||||
@@ -99,7 +99,7 @@ React (5173) → FastAPI (8100) → [Redis, RabbitMQ, PostgreSQL]
|
|||||||
## 📁 Структура проекта
|
## 📁 Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
erv_platform/
|
ticket_form/
|
||||||
├─ backend/ ← Python FastAPI
|
├─ backend/ ← Python FastAPI
|
||||||
│ ├─ app/
|
│ ├─ app/
|
||||||
│ │ ├─ main.py
|
│ │ ├─ main.py
|
||||||
|
|||||||
@@ -1155,3 +1155,5 @@ HTTP 200 OK
|
|||||||
**Автор:** AI Assistant (Claude Sonnet 4.5)
|
**Автор:** AI Assistant (Claude Sonnet 4.5)
|
||||||
**Дата:** 01-02 ноября 2025, 21:00-01:15 MSK
|
**Дата:** 01-02 ноября 2025, 21:00-01:15 MSK
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
112
SUMMARY_DOCUMENTS_API.md
Normal file
112
SUMMARY_DOCUMENTS_API.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# 📎 ИТОГ: API привязки документов готов!
|
||||||
|
|
||||||
|
## ✅ Что сделано
|
||||||
|
|
||||||
|
### 1️⃣ Backend Endpoint
|
||||||
|
**URL:** `POST https://crm.clientright.ru/api/n8n/documents/attach`
|
||||||
|
|
||||||
|
**Возможности:**
|
||||||
|
- ✅ Batch-обработка массива документов
|
||||||
|
- ✅ Умный парсинг S3 путей (автоматически добавляет хост)
|
||||||
|
- ✅ Поддержка двух форматов полей (`file`/`file_url`, `filename`/`file_name`)
|
||||||
|
- ✅ Привязка к HelpDesk (заявке) или Project (проекту)
|
||||||
|
- ✅ Детальная статистика по каждому документу
|
||||||
|
- ✅ Полное логирование всех операций
|
||||||
|
|
||||||
|
### 2️⃣ PHP Backend
|
||||||
|
**Файл:** `/var/www/fastuser/data/www/crm.clientright.ru/upload_documents_to_crm.php`
|
||||||
|
|
||||||
|
**Доработки:**
|
||||||
|
- ✅ Поддержка `ticket_id` для привязки к HelpDesk
|
||||||
|
- ✅ Логика: если `ticket_id` → HelpDesk, иначе → Project
|
||||||
|
- ✅ Обновление S3 метаданных в базе vTiger
|
||||||
|
- ✅ Прямая привязка через `relateEntities` если webservice не работает
|
||||||
|
|
||||||
|
### 3️⃣ Документация
|
||||||
|
- 📄 `DOCUMENT_ATTACH_API.md` - полная документация API
|
||||||
|
- 📄 `QUICK_START_DOCUMENTS.md` - краткая шпаргалка
|
||||||
|
- 📄 `TEST_ATTACH_DOCUMENT.md` - примеры тестирования
|
||||||
|
|
||||||
|
### 4️⃣ Тесты
|
||||||
|
- 🧪 `TEST_REAL_DATA.sh` - тест с реальными данными
|
||||||
|
- 🧪 `TEST_QUICK.sh` - быстрые тесты
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Формат входных данных
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"claim_id": "CLM-2025-11-02-WNRZZZ",
|
||||||
|
"event_type": "delay_flight",
|
||||||
|
"contact_id": "320096",
|
||||||
|
"project_id": "396868",
|
||||||
|
"ticket_id": "396936",
|
||||||
|
"filename": "boarding_pass.pdf",
|
||||||
|
"file_type": "flight_delay_boarding_or_ticket",
|
||||||
|
"file": "/bucket/path/to/file.pdf"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:**
|
||||||
|
- Всегда массив `[...]` (даже для одного документа)
|
||||||
|
- Поле `file` без хоста → автоматически добавится `https://s3.twcstorage.ru`
|
||||||
|
- `ticket_id` опционально (если есть → HelpDesk, иначе → Project)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Формат ответа
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"total_processed": 1,
|
||||||
|
"successful": 1,
|
||||||
|
"failed": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"document_id": "15x396941",
|
||||||
|
"document_numeric_id": "396941",
|
||||||
|
"attached_to": "ticket",
|
||||||
|
"attached_to_id": "396936",
|
||||||
|
"file_name": "boarding_pass.pdf",
|
||||||
|
"file_type": "flight_delay_boarding_or_ticket",
|
||||||
|
"s3_bucket": "f9825c87-...",
|
||||||
|
"s3_key": "crm2/CRM_Active_Files/...",
|
||||||
|
"file_size": 85320,
|
||||||
|
"message": "Документ создан и привязан..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"errors": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Тестирование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/fastuser/data/www/crm.clientright.ru/erv_platform
|
||||||
|
./TEST_REAL_DATA.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Git коммиты
|
||||||
|
|
||||||
|
```
|
||||||
|
ec44f43 - docs: Добавлена краткая шпаргалка для быстрого старта
|
||||||
|
efb0cd6 - feat: Поддержка batch-обработки документов и умного парсинга S3 путей
|
||||||
|
e27280e - docs: Добавлена полная документация API привязки документов
|
||||||
|
936cea6 - feat: Добавлен эндпоинт для привязки документов к проекту/заявке
|
||||||
|
d3b7b3b - feat: Добавлены все N8N webhook URLs в config.py
|
||||||
|
5f4f992 - feat: Добавлена поддержка привязки документов к HelpDesk (CRM)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Готово к боевому использованию!
|
||||||
|
|
||||||
|
Эндпоинт протестирован и готов к интеграции в n8n workflow! 🚀
|
||||||
@@ -127,3 +127,4 @@ tail -f /var/www/fastuser/data/www/crm.clientright.ru/logs/upload_documents.log
|
|||||||
|
|
||||||
Эндпоинт готов к интеграции в n8n workflow!
|
Эндпоинт готов к интеграции в n8n workflow!
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,4 @@ curl -X POST "https://crm.clientright.ru/api/n8n/documents/attach" \
|
|||||||
echo ""
|
echo ""
|
||||||
echo "✅ Тесты завершены!"
|
echo "✅ Тесты завершены!"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Claims API Routes - Обработка заявок
|
Claims API Routes - Обработка заявок
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Request, Query
|
||||||
|
from typing import Optional, List
|
||||||
|
import httpx
|
||||||
from .models import (
|
from .models import (
|
||||||
ClaimCreateRequest,
|
ClaimCreateRequest,
|
||||||
ClaimResponse,
|
ClaimResponse,
|
||||||
@@ -12,42 +14,374 @@ from datetime import datetime
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from ..services.redis_service import redis_service
|
from ..services.redis_service import redis_service
|
||||||
|
from ..services.database import db
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/claims", tags=["Claims"])
|
router = APIRouter(prefix="/api/v1/claims", tags=["Claims"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
N8N_TICKET_FORM_FINAL_WEBHOOK = "https://n8n.clientright.pro/webhook/ecc93306-fadc-489a-afdb-d3e981013df3"
|
||||||
|
|
||||||
@router.post("/create", response_model=ClaimResponse)
|
|
||||||
async def create_claim(claim: ClaimCreateRequest):
|
@router.post("/wizard")
|
||||||
|
async def submit_wizard(request: Request):
|
||||||
"""
|
"""
|
||||||
Создать новую заявку
|
Отправка данных визарда (вопросы + файлы) в n8n через multipart/form-data.
|
||||||
|
|
||||||
Принимает данные формы и создает заявку в системе
|
Вход: multipart/form-data с полями (stage=wizard, form_id, session_id, claim_id, ...),
|
||||||
|
JSON-строками (wizard_plan, wizard_answers, files_meta, ...) и файлами.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Генерируем ID и номер заявки
|
form = await request.form()
|
||||||
claim_id = str(uuid.uuid4())
|
|
||||||
claim_number = f"ERV-{datetime.now().strftime('%Y%m%d')}-{claim_id[:8].upper()}"
|
data: dict[str, str] = {}
|
||||||
|
files: dict[str, tuple] = {}
|
||||||
# TODO: Сохранить в PostgreSQL
|
|
||||||
# TODO: Отправить в очередь RabbitMQ для обработки
|
for key, value in form.multi_items():
|
||||||
# TODO: Интеграция с CRM
|
# В starlette UploadFile — это другой класс, чем fastapi.UploadFile,
|
||||||
|
# поэтому проверяем по наличию атрибутов, а не по isinstance.
|
||||||
return ClaimResponse(
|
if hasattr(value, "filename") and hasattr(value, "read"):
|
||||||
success=True,
|
file_bytes = await value.read()
|
||||||
claim_id=claim_id,
|
files[key] = (value.filename, file_bytes, value.content_type)
|
||||||
claim_number=claim_number,
|
else:
|
||||||
message=f"Заявка {claim_number} успешно создана"
|
# Приводим всё к строкам, включая JSON-строки
|
||||||
|
data[key] = str(value)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"📨 TicketForm wizard submit received",
|
||||||
|
extra={
|
||||||
|
"claim_id": data.get("claim_id"),
|
||||||
|
"session_id": data.get("session_id"),
|
||||||
|
"files": list(files.keys()),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
N8N_TICKET_FORM_FINAL_WEBHOOK,
|
||||||
|
data=data,
|
||||||
|
files=files or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = response.text or ""
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
logger.info(
|
||||||
|
"✅ TicketForm wizard webhook OK",
|
||||||
|
extra={"response_preview": text[:500]},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Wizard workflow started (non-JSON response from n8n)",
|
||||||
|
"raw": text,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
"❌ TicketForm wizard webhook error",
|
||||||
|
extra={"status_code": response.status_code, "body": text[:500]},
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=response.status_code,
|
||||||
|
detail=f"n8n error: {text}",
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.error("⏱️ n8n wizard webhook timeout")
|
||||||
|
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n (wizard)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.exception("❌ Ошибка при отправке визарда")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"Ошибка при создании заявки: {str(e)}"
|
detail=f"Ошибка при отправке визарда: {str(e)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create")
|
||||||
|
async def create_claim(request: Request):
|
||||||
|
"""
|
||||||
|
Финальное создание заявки Ticket Form
|
||||||
|
|
||||||
|
Принимает данные формы от фронтенда и пробрасывает их в n8n webhook.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"📨 TicketForm final submit received",
|
||||||
|
extra={
|
||||||
|
"claim_id": body.get("claim_id"),
|
||||||
|
"event_type": body.get("event_type"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проксируем запрос к n8n
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
N8N_TICKET_FORM_FINAL_WEBHOOK,
|
||||||
|
json=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
text = response.text or ""
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
logger.info(
|
||||||
|
"✅ TicketForm final webhook OK",
|
||||||
|
extra={"response_preview": text[:500]},
|
||||||
|
)
|
||||||
|
# Если n8n вернул JSON — пробрасываем как есть
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except Exception:
|
||||||
|
# Если не JSON, возвращаем обёртку
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Workflow started (non-JSON response from n8n)",
|
||||||
|
"raw": text,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
"❌ TicketForm final webhook error",
|
||||||
|
extra={
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"body": text[:500],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=response.status_code,
|
||||||
|
detail=f"n8n error: {text}",
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.error("⏱️ n8n final webhook timeout")
|
||||||
|
raise HTTPException(status_code=504, detail="Таймаут подключения к n8n")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("❌ Ошибка при финальной отправке заявки")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Ошибка при создании заявки: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/drafts/list")
|
||||||
|
async def list_drafts(
|
||||||
|
unified_id: Optional[str] = Query(None, description="Unified ID пользователя для поиска черновиков"),
|
||||||
|
phone: Optional[str] = Query(None, description="Номер телефона для поиска (fallback, если unified_id не указан)"),
|
||||||
|
session_id: Optional[str] = Query(None, description="Session ID для поиска (fallback, если unified_id не указан)")
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Получить список всех заявок для пользователя (все статусы)
|
||||||
|
|
||||||
|
Приоритет поиска:
|
||||||
|
1. unified_id (основной способ) - ищет по clpr_claims.unified_id
|
||||||
|
2. phone (fallback) - ищет через clpr_user_accounts и clpr_users
|
||||||
|
3. session_id (fallback) - ищет по session_token
|
||||||
|
|
||||||
|
Возвращает все заявки с колонкой status_code для фильтрации на фронтенде
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not unified_id and not phone and not session_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Необходимо указать unified_id, phone или session_id")
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.payload->>'claim_id' as claim_id,
|
||||||
|
c.session_token,
|
||||||
|
c.status_code,
|
||||||
|
c.channel,
|
||||||
|
c.payload,
|
||||||
|
c.created_at,
|
||||||
|
c.updated_at
|
||||||
|
FROM clpr_claims c
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if unified_id:
|
||||||
|
# Основной способ - поиск по unified_id
|
||||||
|
query += " AND c.unified_id = $1"
|
||||||
|
params.append(unified_id)
|
||||||
|
elif phone:
|
||||||
|
# Fallback: ищем через clpr_user_accounts и clpr_users
|
||||||
|
query += """
|
||||||
|
AND c.unified_id = (
|
||||||
|
SELECT u.unified_id
|
||||||
|
FROM clpr_user_accounts ua
|
||||||
|
JOIN clpr_users u ON u.id = ua.user_id
|
||||||
|
WHERE ua.channel = 'web_form'
|
||||||
|
AND ua.channel_user_id = $1
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
params.append(phone)
|
||||||
|
elif session_id:
|
||||||
|
# Fallback: поиск по session_token
|
||||||
|
query += " AND c.session_token = $1"
|
||||||
|
params.append(session_id)
|
||||||
|
|
||||||
|
query += " ORDER BY c.updated_at DESC LIMIT 20"
|
||||||
|
|
||||||
|
# Простой тест: проверяем, что unified_id вообще есть в базе
|
||||||
|
test_count = 0
|
||||||
|
if unified_id:
|
||||||
|
try:
|
||||||
|
test_count = await db.fetch_val("SELECT COUNT(*) FROM clpr_claims WHERE unified_id = $1", unified_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Ошибка тестового COUNT: {e}")
|
||||||
|
|
||||||
|
rows = await db.fetch_all(query, *params)
|
||||||
|
|
||||||
|
# ВРЕМЕННО: возвращаем тестовые данные для отладки
|
||||||
|
debug_info = {
|
||||||
|
"unified_id": unified_id,
|
||||||
|
"test_count": test_count,
|
||||||
|
"rows_found": len(rows),
|
||||||
|
"query": query[:100] if len(query) > 100 else query,
|
||||||
|
"params": params
|
||||||
|
}
|
||||||
|
|
||||||
|
drafts = []
|
||||||
|
for row in rows:
|
||||||
|
# Обрабатываем payload - может быть строкой (JSONB) или уже dict
|
||||||
|
payload_raw = row.get('payload')
|
||||||
|
if isinstance(payload_raw, str):
|
||||||
|
try:
|
||||||
|
payload = json.loads(payload_raw) if payload_raw else {}
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
payload = {}
|
||||||
|
elif isinstance(payload_raw, dict):
|
||||||
|
payload = payload_raw
|
||||||
|
else:
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
drafts.append({
|
||||||
|
"id": str(row['id']),
|
||||||
|
"claim_id": row.get('claim_id'),
|
||||||
|
"session_token": row.get('session_token'),
|
||||||
|
"status_code": row.get('status_code'),
|
||||||
|
"channel": row.get('channel'), # Добавляем канал в ответ
|
||||||
|
"created_at": row['created_at'].isoformat() if row.get('created_at') else None,
|
||||||
|
"updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None,
|
||||||
|
"problem_description": payload.get('problem_description', '')[:100] if payload.get('problem_description') else None,
|
||||||
|
"wizard_plan": payload.get('wizard_plan') is not None,
|
||||||
|
"wizard_answers": payload.get('answers') is not None,
|
||||||
|
"has_documents": len(payload.get('documents_meta', [])) > 0 if payload.get('documents_meta') else False,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"count": len(drafts),
|
||||||
|
"drafts": drafts
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("❌ Ошибка при получении списка черновиков")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Ошибка при получении черновиков: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/drafts/{claim_id}")
|
||||||
|
async def get_draft(claim_id: str):
|
||||||
|
"""
|
||||||
|
Получить полные данные черновика по claim_id
|
||||||
|
|
||||||
|
Возвращает все данные формы для продолжения заполнения
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
payload->>'claim_id' as claim_id,
|
||||||
|
session_token,
|
||||||
|
status_code,
|
||||||
|
payload,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM clpr_claims
|
||||||
|
WHERE payload->>'claim_id' = $1
|
||||||
|
AND status_code = 'draft'
|
||||||
|
AND channel = 'web_form'
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
|
||||||
|
row = await db.fetch_one(query, claim_id)
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Черновик не найден")
|
||||||
|
|
||||||
|
# Обрабатываем payload - может быть строкой (JSONB) или уже dict
|
||||||
|
payload_raw = row.get('payload')
|
||||||
|
if isinstance(payload_raw, str):
|
||||||
|
try:
|
||||||
|
payload = json.loads(payload_raw) if payload_raw else {}
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
payload = {}
|
||||||
|
elif isinstance(payload_raw, dict):
|
||||||
|
payload = payload_raw
|
||||||
|
else:
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"claim": {
|
||||||
|
"id": str(row['id']),
|
||||||
|
"claim_id": row.get('claim_id'),
|
||||||
|
"session_token": row.get('session_token'),
|
||||||
|
"status_code": row.get('status_code'),
|
||||||
|
"created_at": row['created_at'].isoformat() if row.get('created_at') else None,
|
||||||
|
"updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None,
|
||||||
|
"payload": payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("❌ Ошибка при получении черновика")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Ошибка при получении черновика: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/drafts/{claim_id}")
|
||||||
|
async def delete_draft(claim_id: str):
|
||||||
|
"""
|
||||||
|
Удалить черновик по claim_id
|
||||||
|
|
||||||
|
Удаляет только черновики (status_code = 'draft')
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
query = """
|
||||||
|
DELETE FROM clpr_claims
|
||||||
|
WHERE payload->>'claim_id' = $1
|
||||||
|
AND status_code = 'draft'
|
||||||
|
AND channel = 'web_form'
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
|
||||||
|
deleted_id = await db.fetch_val(query, claim_id)
|
||||||
|
|
||||||
|
if not deleted_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Черновик не найден или уже удален")
|
||||||
|
|
||||||
|
logger.info(f"✅ Черновик удален: {claim_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Черновик успешно удален",
|
||||||
|
"claim_id": claim_id
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("❌ Ошибка при удалении черновика")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Ошибка при удалении черновика: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{claim_id}")
|
@router.get("/{claim_id}")
|
||||||
async def get_claim(claim_id: str):
|
async def get_claim(claim_id: str):
|
||||||
"""Получить информацию о заявке по ID"""
|
"""Получить информацию о заявке по ID"""
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ async def stream_events(task_id: str):
|
|||||||
# Слушаем события
|
# Слушаем события
|
||||||
while True:
|
while True:
|
||||||
logger.info(f"⏳ Waiting for message on {channel}...")
|
logger.info(f"⏳ Waiting for message on {channel}...")
|
||||||
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=30.0)
|
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=60.0) # Увеличено для RAG обработки
|
||||||
|
|
||||||
if message:
|
if message:
|
||||||
logger.info(f"📥 Received message type: {message['type']}")
|
logger.info(f"📥 Received message type: {message['type']}")
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ async def proxy_policy_check(request: Request):
|
|||||||
try:
|
try:
|
||||||
# Получаем JSON body от фронтенда
|
# Получаем JSON body от фронтенда
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
body.setdefault('form_id', 'ticket_form')
|
||||||
|
|
||||||
logger.info(f"🔄 Proxy policy check: {body.get('policy_number', 'unknown')}")
|
logger.info(f"🔄 Proxy policy check: {body.get('policy_number', 'unknown')}")
|
||||||
|
|
||||||
@@ -85,7 +86,12 @@ async def proxy_create_contact(request: Request):
|
|||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
|
||||||
logger.info(f"🔄 Proxy create contact: phone={body.get('phone', 'unknown')}, session_id={body.get('session_id', 'unknown')}")
|
logger.info(
|
||||||
|
"🔄 Proxy create contact: phone=%s, session_id=%s, form_id=%s",
|
||||||
|
body.get('phone', 'unknown'),
|
||||||
|
body.get('session_id', 'unknown'),
|
||||||
|
body.get('form_id', 'missing')
|
||||||
|
)
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
@@ -175,8 +181,27 @@ async def proxy_file_upload(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
|
response_text = response.text
|
||||||
logger.info(f"✅ File upload success")
|
logger.info(f"✅ File upload success")
|
||||||
return response.json()
|
|
||||||
|
if not response_text or response_text.strip() == '':
|
||||||
|
# n8n может вернуть пустой ответ, возвращаем заглушку
|
||||||
|
logger.warning("⚠️ N8N upload webhook вернул пустой ответ, подставляю default payload")
|
||||||
|
return {"success": True, "message": "n8n: empty response"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Не удалось распарсить JSON от n8n: {e}. Response: {response_text[:500]}")
|
||||||
|
# Возвращаем текстовое содержимое чтобы фронт мог показать пользователю
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=200,
|
||||||
|
content={
|
||||||
|
"success": True,
|
||||||
|
"message": "n8n upload returned non-JSON response",
|
||||||
|
"raw": response_text
|
||||||
|
}
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.error(f"❌ N8N returned {response.status_code}: {response.text}")
|
logger.error(f"❌ N8N returned {response.status_code}: {response.text}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Ticket Form Intake Platform - FastAPI Backend
|
Ticket Form Intake Platform - FastAPI Backend
|
||||||
"""
|
"""
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import logging
|
import logging
|
||||||
@@ -189,6 +189,15 @@ async def test():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/utils/client-ip")
|
||||||
|
async def get_client_ip(request: Request):
|
||||||
|
"""Возвращает IP-адрес клиента по HTTP-запросу"""
|
||||||
|
client_host = request.client.host if request.client else None
|
||||||
|
return {
|
||||||
|
"ip": client_host
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/info")
|
@app.get("/api/v1/info")
|
||||||
async def info():
|
async def info():
|
||||||
"""Информация о платформе"""
|
"""Информация о платформе"""
|
||||||
|
|||||||
210
docs/CLAIMSAVE_FINAL_SQL.md
Normal file
210
docs/CLAIMSAVE_FINAL_SQL.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# Исправленный SQL для ноды `claimsave_final`
|
||||||
|
|
||||||
|
## Текущая проблема
|
||||||
|
|
||||||
|
Нода `claimsave_final` использует `$2::uuid`, но получает строку `"CLM-2025-11-18-GEQ3KL"`, что вызывает ошибку.
|
||||||
|
|
||||||
|
## Особенности `claimsave_final`
|
||||||
|
|
||||||
|
1. Используется **после конвертации файлов в PDF** и загрузки в S3
|
||||||
|
2. Работает с `file_url` (URL файла в S3)
|
||||||
|
3. Обновляет только `documents_meta` в payload (не трогает `answers`)
|
||||||
|
4. Использует динамический префикс таблицы (для разных схем)
|
||||||
|
|
||||||
|
## Исправленный SQL запрос
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- $1 = payload_partial_json (jsonb)
|
||||||
|
-- $2 = claim_id (text, например "CLM-2025-11-18-GEQ3KL")
|
||||||
|
|
||||||
|
WITH partial AS (
|
||||||
|
SELECT $1::jsonb AS p, $2::text AS claim_id_str
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Находим UUID по строковому claim_id
|
||||||
|
claim_lookup AS (
|
||||||
|
SELECT
|
||||||
|
COALESCE(
|
||||||
|
(SELECT id FROM clpr_claims WHERE payload->>'claim_id' = partial.claim_id_str LIMIT 1),
|
||||||
|
gen_random_uuid()
|
||||||
|
) AS claim_uuid
|
||||||
|
FROM partial
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Если записи нет, создаем её (на всякий случай)
|
||||||
|
claim_created AS (
|
||||||
|
INSERT INTO clpr_claims (
|
||||||
|
id,
|
||||||
|
session_token,
|
||||||
|
channel,
|
||||||
|
type_code,
|
||||||
|
status_code,
|
||||||
|
payload,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
expires_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
claim_lookup.claim_uuid,
|
||||||
|
COALESCE(partial.p->>'session_id', 'sess-' || gen_random_uuid()::text),
|
||||||
|
'web_form',
|
||||||
|
COALESCE(partial.p->>'type_code', 'consumer'),
|
||||||
|
'draft',
|
||||||
|
jsonb_build_object(
|
||||||
|
'claim_id', partial.claim_id_str,
|
||||||
|
'documents_meta', COALESCE(partial.p->'documents_meta', '[]'::jsonb)
|
||||||
|
),
|
||||||
|
now(),
|
||||||
|
now(),
|
||||||
|
now() + interval '14 days'
|
||||||
|
FROM partial, claim_lookup
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM clpr_claims WHERE id = claim_lookup.claim_uuid
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Получаем финальный UUID
|
||||||
|
claim_final AS (
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN EXISTS (SELECT 1 FROM claim_created)
|
||||||
|
THEN (SELECT id FROM claim_created LIMIT 1)
|
||||||
|
ELSE claim_lookup.claim_uuid
|
||||||
|
END AS claim_uuid
|
||||||
|
FROM claim_lookup
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Извлекаем документы из payload
|
||||||
|
docs AS (
|
||||||
|
SELECT
|
||||||
|
claim_final.claim_uuid::text AS claim_id, -- преобразуем UUID в строку для clpr_claim_documents
|
||||||
|
doc.field_name::text,
|
||||||
|
doc.file_id::text,
|
||||||
|
doc.file_name::text,
|
||||||
|
doc.original_file_name::text,
|
||||||
|
(doc.uploaded_at)::timestamptz AS uploaded_at,
|
||||||
|
doc.file_url::text
|
||||||
|
FROM partial, claim_final
|
||||||
|
CROSS JOIN LATERAL jsonb_to_recordset(
|
||||||
|
COALESCE(partial.p->'documents_meta','[]'::jsonb)
|
||||||
|
) AS doc(
|
||||||
|
field_name text,
|
||||||
|
file_id text,
|
||||||
|
file_name text,
|
||||||
|
original_file_name text,
|
||||||
|
uploaded_at text,
|
||||||
|
file_url text
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Сохраняем/обновляем документы
|
||||||
|
upsert_docs AS (
|
||||||
|
INSERT INTO clpr_claim_documents
|
||||||
|
(claim_id, field_name, file_id, uploaded_at, file_name, original_file_name)
|
||||||
|
SELECT
|
||||||
|
claim_id,
|
||||||
|
field_name,
|
||||||
|
file_id,
|
||||||
|
uploaded_at,
|
||||||
|
file_name,
|
||||||
|
original_file_name
|
||||||
|
FROM docs
|
||||||
|
ON CONFLICT (claim_id, field_name) DO UPDATE
|
||||||
|
SET file_id = EXCLUDED.file_id,
|
||||||
|
uploaded_at = EXCLUDED.uploaded_at,
|
||||||
|
file_name = EXCLUDED.file_name,
|
||||||
|
original_file_name = EXCLUDED.original_file_name
|
||||||
|
RETURNING id, claim_id, field_name, file_id
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Обновляем payload (только documents_meta, не трогаем answers)
|
||||||
|
upd_claim AS (
|
||||||
|
UPDATE clpr_claims c
|
||||||
|
SET
|
||||||
|
payload = jsonb_set(
|
||||||
|
COALESCE(c.payload, '{}'::jsonb),
|
||||||
|
'{documents_meta}',
|
||||||
|
COALESCE((SELECT p->'documents_meta' FROM partial), '[]'::jsonb),
|
||||||
|
true
|
||||||
|
),
|
||||||
|
updated_at = now(),
|
||||||
|
expires_at = now() + interval '14 days'
|
||||||
|
FROM partial, claim_final
|
||||||
|
WHERE c.id = claim_final.claim_uuid
|
||||||
|
RETURNING c.id, c.payload
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
(SELECT jsonb_build_object(
|
||||||
|
'claim_id', u.id::text,
|
||||||
|
'claim_id_str', (u.payload->>'claim_id'),
|
||||||
|
'payload', u.payload
|
||||||
|
) FROM upd_claim u LIMIT 1) AS claim,
|
||||||
|
(
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'id', u.id,
|
||||||
|
'field_name', u.field_name,
|
||||||
|
'file_id', u.file_id,
|
||||||
|
'file_url', d.file_url,
|
||||||
|
'file_name', d.file_name,
|
||||||
|
'original_file_name', d.original_file_name,
|
||||||
|
'uploaded_at', d.uploaded_at,
|
||||||
|
-- имя, которое безопасно отдавать во внешний API
|
||||||
|
'filename_for_upload',
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(d.original_file_name, ''),
|
||||||
|
NULLIF(d.file_name, ''),
|
||||||
|
regexp_replace(d.file_id, '^.*/', '') -- хвост пути как запасной
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM upsert_docs u
|
||||||
|
JOIN docs d
|
||||||
|
ON d.claim_id = u.claim_id
|
||||||
|
AND d.field_name = u.field_name
|
||||||
|
WHERE d.file_url IS NOT NULL AND d.file_url <> '' -- не показываем без URL
|
||||||
|
) AS documents;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Изменения
|
||||||
|
|
||||||
|
1. **`$2::text` вместо `$2::uuid`**: Принимает строковый `claim_id`
|
||||||
|
2. **`claim_lookup` CTE**: Находит UUID по строковому `claim_id` из `payload->>'claim_id'`
|
||||||
|
3. **`claim_created` CTE**: Создает запись, если её нет (на всякий случай)
|
||||||
|
4. **`claim_final` CTE**: Получает финальный UUID (из созданной или существующей записи)
|
||||||
|
5. **`docs` CTE**: Преобразует UUID в строку для `clpr_claim_documents` (т.к. там `claim_id` имеет тип `character varying`)
|
||||||
|
6. **Убраны динамические префиксы**: Используется `clpr_claims` и `clpr_claim_documents` напрямую
|
||||||
|
|
||||||
|
## Параметры запроса
|
||||||
|
|
||||||
|
В n8n PostgreSQL Node:
|
||||||
|
```
|
||||||
|
Parameters:
|
||||||
|
$1 = {{ $json.payload_partial_json }} (JSONB)
|
||||||
|
$2 = {{ $json.claim_id }} (TEXT, строка "CLM-2025-11-18-GEQ3KL")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Если нужен динамический префикс
|
||||||
|
|
||||||
|
Если всё-таки нужен динамический префикс таблицы (как в оригинале), можно использовать:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Вместо clpr_claims использовать:
|
||||||
|
{{ $('Edit Fields').item.json.propertyName.prefix }}claims
|
||||||
|
|
||||||
|
-- Вместо clpr_claim_documents использовать:
|
||||||
|
{{ $('Edit Fields').item.json.propertyName.prefix }}claim_documents
|
||||||
|
```
|
||||||
|
|
||||||
|
Но для `ticket_form` это не нужно, т.к. мы всегда работаем с `clpr_*` таблицами.
|
||||||
|
|
||||||
|
## Отличия от `claimsave`
|
||||||
|
|
||||||
|
1. **`claimsave`**: Сохраняет данные визарда (answers, wizard_plan, wizard_answers)
|
||||||
|
2. **`claimsave_final`**: Обновляет только `documents_meta` после обработки файлов, добавляет `file_url`
|
||||||
|
|
||||||
|
Оба запроса теперь используют строковый `claim_id` и правильно находят UUID.
|
||||||
|
|
||||||
103
docs/CODE1_FIX.md
Normal file
103
docs/CODE1_FIX.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# Исправление ошибки в Code1: mapDialogHistory
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
**Ошибка:**
|
||||||
|
```
|
||||||
|
Cannot read properties of null (reading 'map') [line 69]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Причина:**
|
||||||
|
Функция `mapDialogHistory` получает `null` вместо массива, когда `src.dialog_history` равен `null`.
|
||||||
|
|
||||||
|
## Исправление
|
||||||
|
|
||||||
|
### Текущий код (строка 69):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function mapDialogHistory(h = []) {
|
||||||
|
return h.map(m => ({
|
||||||
|
id: toNullish(m.id),
|
||||||
|
role: toNullish(m.role),
|
||||||
|
message: toNullish(m.message),
|
||||||
|
message_type: toNullish(m.message_type),
|
||||||
|
tg_message_id: toNullish(m.tg_message_id),
|
||||||
|
created_at: toNullish(m.created_at),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Исправленный код:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function mapDialogHistory(h = []) {
|
||||||
|
// Проверяем, что h не null и является массивом
|
||||||
|
if (!h || !Array.isArray(h)) return [];
|
||||||
|
return h.map(m => ({
|
||||||
|
id: toNullish(m.id),
|
||||||
|
role: toNullish(m.role),
|
||||||
|
message: toNullish(m.message),
|
||||||
|
message_type: toNullish(m.message_type),
|
||||||
|
tg_message_id: toNullish(m.tg_message_id),
|
||||||
|
created_at: toNullish(m.created_at),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Альтернативное решение
|
||||||
|
|
||||||
|
Можно также исправить в месте вызова:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// В функции normalizeOne, строка ~172
|
||||||
|
dialog_history: mapDialogHistory(src.dialog_history || []),
|
||||||
|
```
|
||||||
|
|
||||||
|
Но лучше исправить саму функцию, чтобы она была более устойчивой.
|
||||||
|
|
||||||
|
## Полный исправленный код функции mapDialogHistory
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function mapDialogHistory(h = []) {
|
||||||
|
// Проверяем, что h не null и является массивом
|
||||||
|
if (!h || !Array.isArray(h)) return [];
|
||||||
|
return h.map(m => ({
|
||||||
|
id: toNullish(m.id),
|
||||||
|
role: toNullish(m.role),
|
||||||
|
message: toNullish(m.message),
|
||||||
|
message_type: toNullish(m.message_type),
|
||||||
|
tg_message_id: toNullish(m.tg_message_id),
|
||||||
|
created_at: toNullish(m.created_at),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Почему это происходит
|
||||||
|
|
||||||
|
Когда SQL запрос в ноде `give_data1` возвращает `null` для `dialog_history` (если нет записей в `clpr_dialog_history_tg`), функция `mapDialogHistory` получает `null` вместо массива.
|
||||||
|
|
||||||
|
PostgreSQL `jsonb_agg` возвращает `null`, если нет строк для агрегации, а не пустой массив `[]`.
|
||||||
|
|
||||||
|
## Дополнительные проверки
|
||||||
|
|
||||||
|
Можно также добавить проверки для других функций, которые работают с массивами:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function mapDocuments(docs = []) {
|
||||||
|
if (!docs || !Array.isArray(docs)) return [];
|
||||||
|
return docs.map(d => ({...}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapVisionDocs(vds = []) {
|
||||||
|
if (!vds || !Array.isArray(vds)) return [];
|
||||||
|
return vds.map(v => ({...}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCombinedDocs(cds = []) {
|
||||||
|
if (!cds || !Array.isArray(cds)) return [];
|
||||||
|
return cds.map(c => ({...}));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Но для `mapDialogHistory` это критично, т.к. она вызывается первой и падает.
|
||||||
|
|
||||||
212
docs/CODE1_FIXED_CODE.js
Normal file
212
docs/CODE1_FIXED_CODE.js
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// Code node (JavaScript). Input: items[0].json = либо объект, либо массив таких объектов, как ты прислал.
|
||||||
|
// Output: по одному нормализованному объекту на кейс.
|
||||||
|
// Никаких внешних зависимостей, всё на ванильном JS.
|
||||||
|
|
||||||
|
function toNullish(v) {
|
||||||
|
if (v === undefined || v === null) return null;
|
||||||
|
if (typeof v === 'string' && v.trim() === '') return null;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(o, path, def = null) {
|
||||||
|
try {
|
||||||
|
return toNullish(path.split('.').reduce((acc, k) => (acc == null ? undefined : acc[k]), o));
|
||||||
|
} catch {
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDocuments(docs = []) {
|
||||||
|
// Проверяем, что docs не null и является массивом
|
||||||
|
if (!docs || !Array.isArray(docs)) return [];
|
||||||
|
return docs.map(d => ({
|
||||||
|
id: toNullish(d.id),
|
||||||
|
claim_document_id: toNullish(d.id), // у тебя id = claim_document_id
|
||||||
|
file_id: toNullish(d.file_id),
|
||||||
|
file_url: toNullish(d.file_url),
|
||||||
|
file_name: toNullish(d.file_name),
|
||||||
|
original_file_name: toNullish(d.original_file_name),
|
||||||
|
field_name: toNullish(d.field_name),
|
||||||
|
upload_description: toNullish(d.upload_description),
|
||||||
|
uploaded_at: toNullish(d.uploaded_at),
|
||||||
|
filename_for_upload: toNullish(d.filename_for_upload),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapVisionDocs(vds = []) {
|
||||||
|
// Проверяем, что vds не null и является массивом
|
||||||
|
if (!vds || !Array.isArray(vds)) return [];
|
||||||
|
return vds.map(v => ({
|
||||||
|
claim_document_id: toNullish(v.claim_document_id),
|
||||||
|
vision_document_id: toNullish(v.vision_document_id),
|
||||||
|
pages: toNullish(v.pages),
|
||||||
|
content_sha256: toNullish(v.content_sha256),
|
||||||
|
vision_text: toNullish(v.vision_text),
|
||||||
|
vision_pages: Array.isArray(v.vision_pages)
|
||||||
|
? v.vision_pages.map(p => ({
|
||||||
|
page: toNullish(p.page),
|
||||||
|
uid: toNullish(p.uid),
|
||||||
|
}))
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCombinedDocs(cds = []) {
|
||||||
|
// Проверяем, что cds не null и является массивом
|
||||||
|
if (!cds || !Array.isArray(cds)) return [];
|
||||||
|
return cds.map(c => ({
|
||||||
|
claim_document_id: toNullish(c.claim_document_id),
|
||||||
|
combined_document_id: toNullish(c.combined_document_id),
|
||||||
|
pages: toNullish(c.pages),
|
||||||
|
content_sha256: toNullish(c.content_sha256),
|
||||||
|
combined_text: toNullish(c.combined_text),
|
||||||
|
page_summaries: Array.isArray(c.page_summaries)
|
||||||
|
? c.page_summaries.map(ps => ({
|
||||||
|
page: toNullish(ps.page),
|
||||||
|
chars: toNullish(ps.chars),
|
||||||
|
uid: toNullish(ps.uid),
|
||||||
|
image_url: toNullish(ps.image_url),
|
||||||
|
}))
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDialogHistory(h = []) {
|
||||||
|
// ИСПРАВЛЕНО: Проверяем, что h не null и является массивом
|
||||||
|
if (!h || !Array.isArray(h)) return [];
|
||||||
|
return h.map(m => ({
|
||||||
|
id: toNullish(m.id),
|
||||||
|
role: toNullish(m.role),
|
||||||
|
message: toNullish(m.message),
|
||||||
|
message_type: toNullish(m.message_type),
|
||||||
|
tg_message_id: toNullish(m.tg_message_id),
|
||||||
|
created_at: toNullish(m.created_at),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCoverageReport(cr = null) {
|
||||||
|
if (!cr) return null;
|
||||||
|
return {
|
||||||
|
questions: Array.isArray(cr.questions)
|
||||||
|
? cr.questions.map(q => ({
|
||||||
|
name: toNullish(q.name),
|
||||||
|
value: toNullish(q.value),
|
||||||
|
status: toNullish(q.status),
|
||||||
|
source: toNullish(q.source),
|
||||||
|
confidence: toNullish(q.confidence),
|
||||||
|
}))
|
||||||
|
: null,
|
||||||
|
docs_missing: Array.isArray(cr.docs_missing) ? cr.docs_missing : null,
|
||||||
|
docs_received: Array.isArray(cr.docs_received) ? cr.docs_received : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOne(src) {
|
||||||
|
const claim = src.claim ?? {};
|
||||||
|
const userInfo = src.user_info ?? {};
|
||||||
|
const propertyName = claim.propertyName ?? {};
|
||||||
|
|
||||||
|
// answers_parsed уже есть в claim; не мудрим — возвращаем как есть, пустоты -> null
|
||||||
|
const answersParsed = claim.answers_parsed
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(claim.answers_parsed).map(([k, v]) => [k, toNullish(v)])
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// wizard план (часто нужен на фронте) — оставим ключевые поля
|
||||||
|
let wizard = null;
|
||||||
|
try {
|
||||||
|
const parsed = typeof claim.wizard_plan === 'string'
|
||||||
|
? JSON.parse(claim.wizard_plan)
|
||||||
|
: (claim.wizard_plan_parsed ?? null);
|
||||||
|
if (parsed) {
|
||||||
|
wizard = {
|
||||||
|
version: toNullish(parsed.version),
|
||||||
|
case_type: toNullish(parsed.case_type),
|
||||||
|
goals: Array.isArray(parsed.goals) ? parsed.goals : null,
|
||||||
|
documents: Array.isArray(parsed.documents) ? parsed.documents : null,
|
||||||
|
questions: Array.isArray(parsed.questions) ? parsed.questions : null,
|
||||||
|
risks: Array.isArray(parsed.risks) ? parsed.risks : null,
|
||||||
|
deadlines: Array.isArray(parsed.deadlines) ? parsed.deadlines : null,
|
||||||
|
ask_order: Array.isArray(parsed.ask_order) ? parsed.ask_order : null,
|
||||||
|
notes: toNullish(parsed.notes),
|
||||||
|
user_text: toNullish(parsed.user_text),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
wizard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Склеиваем user — берём user_info, плюс propertyName на всякий, и то, что лежит в диалогах
|
||||||
|
const user = {
|
||||||
|
channel: toNullish(userInfo.channel ?? propertyName.channel),
|
||||||
|
user_id: toNullish(userInfo.user_id ?? propertyName.user_id),
|
||||||
|
unified_id: toNullish(userInfo.unified_id ?? propertyName.unified_id),
|
||||||
|
telegram_id: toNullish(userInfo.telegram_id ?? propertyName.telegram_id ?? claim.telegram_id),
|
||||||
|
session_token: toNullish(userInfo.session_token ?? propertyName.session_token ?? claim.session_token),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Собираем
|
||||||
|
const out = {
|
||||||
|
case: {
|
||||||
|
id: toNullish(pick(claim, 'id')),
|
||||||
|
prefix: toNullish(pick(claim, 'prefix')),
|
||||||
|
channel: toNullish(pick(claim, 'channel')),
|
||||||
|
type_code: toNullish(pick(claim, 'type_code')),
|
||||||
|
status_code: toNullish(pick(claim, 'status_code')),
|
||||||
|
created_at: toNullish(pick(claim, 'created_at')),
|
||||||
|
updated_at: toNullish(pick(claim, 'updated_at')),
|
||||||
|
telegram_id: toNullish(pick(claim, 'telegram_id')),
|
||||||
|
session_token: toNullish(pick(claim, 'session_token')),
|
||||||
|
unified_id: toNullish(pick(claim, 'unified_id')),
|
||||||
|
case_type: toNullish(pick(claim, 'case_type')),
|
||||||
|
},
|
||||||
|
|
||||||
|
user, // см. выше
|
||||||
|
|
||||||
|
answers: answersParsed,
|
||||||
|
|
||||||
|
// что загрузили
|
||||||
|
documents: mapDocuments(src.documents),
|
||||||
|
|
||||||
|
// OCR/Vision/Combined, если есть
|
||||||
|
vision_docs: mapVisionDocs(src.vision_docs),
|
||||||
|
combined_docs: mapCombinedDocs(src.combined_docs),
|
||||||
|
|
||||||
|
// что там в "coverage_report" (кто что заполнил/не заполнил в мастере)
|
||||||
|
coverage_report: mapCoverageReport(pick(claim, 'coverage_report')),
|
||||||
|
|
||||||
|
// история чата (ID, роли, тексты)
|
||||||
|
dialog_history: mapDialogHistory(src.dialog_history),
|
||||||
|
|
||||||
|
// на всякий — куда и что складывали на S3 в момент сохранения
|
||||||
|
s3_manifest: {
|
||||||
|
session_token: toNullish(pick(claim, 'session_token')),
|
||||||
|
documents_meta: Array.isArray(claim.documents_meta) ? claim.documents_meta : null,
|
||||||
|
},
|
||||||
|
|
||||||
|
// флаги/риски, что засетили при сохранении
|
||||||
|
risks: Array.isArray(claim.risks) ? claim.risks : null,
|
||||||
|
|
||||||
|
// план (wizard), как есть — пригодится фронту и валидаторам
|
||||||
|
wizard_plan: wizard,
|
||||||
|
};
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === entrypoint ===
|
||||||
|
const raw = items[0]?.json ?? {};
|
||||||
|
const arr = Array.isArray(raw) ? raw : [raw];
|
||||||
|
|
||||||
|
// опциональный фильтр по claim_id, если в item передадут { claim_id: "..." }
|
||||||
|
const claimIdFilter = items[0]?.json?.claim_id || items[0]?.json?.claimId || null;
|
||||||
|
|
||||||
|
// Прогоняем всё, отдаём по одному Item на кейс
|
||||||
|
const results = arr
|
||||||
|
.map(normalizeOne)
|
||||||
|
.filter(obj => (claimIdFilter ? obj.case.id === claimIdFilter : true))
|
||||||
|
.map(obj => ({ json: obj }));
|
||||||
|
|
||||||
|
return results.length ? results : [{ json: null }];
|
||||||
|
|
||||||
183
docs/DATABASE_SCHEMA.md
Normal file
183
docs/DATABASE_SCHEMA.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
# Схема базы данных clpr_*
|
||||||
|
|
||||||
|
## Основные таблицы
|
||||||
|
|
||||||
|
### 1. `clpr_users` - Основная таблица пользователей
|
||||||
|
```
|
||||||
|
id (integer, PK)
|
||||||
|
universal_id (uuid)
|
||||||
|
unified_id (varchar) ← КЛЮЧЕВОЕ ПОЛЕ для связи
|
||||||
|
phone (varchar)
|
||||||
|
created_at, updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. `clpr_user_accounts` - Связь пользователей с каналами
|
||||||
|
```
|
||||||
|
id (integer, PK)
|
||||||
|
user_id (integer) → FK на clpr_users.id
|
||||||
|
channel (text) - 'telegram', 'web_form'
|
||||||
|
channel_user_id (text) - ID в канале (telegram_id для telegram, phone для web_form)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Связь:**
|
||||||
|
- `clpr_user_accounts.user_id` → `clpr_users.id`
|
||||||
|
- Уникальность: `(channel, channel_user_id)` - один пользователь может быть в нескольких каналах
|
||||||
|
|
||||||
|
### 3. `clpr_claims` - Заявки/черновики
|
||||||
|
```
|
||||||
|
id (uuid, PK)
|
||||||
|
session_token (varchar)
|
||||||
|
unified_id (varchar) ← СВЯЗЬ С clpr_users.unified_id (должен заполняться n8n!)
|
||||||
|
telegram_id (bigint)
|
||||||
|
channel (text) - 'telegram', 'web_form'
|
||||||
|
user_id (integer) - возможно FK на clpr_users.id
|
||||||
|
type_code (text)
|
||||||
|
status_code (text) - 'draft', 'in_work', etc.
|
||||||
|
policy_number (text)
|
||||||
|
payload (jsonb) - содержит phone, claim_id, wizard_plan, answers, documents_meta и т.д.
|
||||||
|
is_confirmed (boolean)
|
||||||
|
created_at, updated_at, expires_at
|
||||||
|
```
|
||||||
|
|
||||||
|
**Связь:**
|
||||||
|
- `clpr_claims.unified_id` → `clpr_users.unified_id` (логическая связь)
|
||||||
|
- `clpr_claims.user_id` → `clpr_users.id` (возможно, не всегда заполнено)
|
||||||
|
|
||||||
|
### 4. `clpr_users_tg` - Данные Telegram пользователей
|
||||||
|
```
|
||||||
|
telegram_id (bigint, PK)
|
||||||
|
unified_id (varchar) → clpr_users.unified_id
|
||||||
|
phone_number (varchar)
|
||||||
|
first_name_tg, last_name_tg, username, language_code, is_premium
|
||||||
|
first_name, last_name, middle_name, birth_date, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. `clpr_claim_documents` - Документы заявок
|
||||||
|
```
|
||||||
|
id (uuid, PK)
|
||||||
|
claim_id (varchar) → clpr_claims.id (логическая связь через payload->>'claim_id')
|
||||||
|
field_name (text)
|
||||||
|
file_id (text)
|
||||||
|
uploaded_at (timestamp)
|
||||||
|
file_name, original_file_name
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. `clpr_documents` - Хранилище документов
|
||||||
|
```
|
||||||
|
id (uuid, PK)
|
||||||
|
source (text)
|
||||||
|
content (text)
|
||||||
|
metadata (jsonb)
|
||||||
|
created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
## Логика работы с черновиками для web_form
|
||||||
|
|
||||||
|
### Шаг 1: Проверка пользователя в CRM
|
||||||
|
- n8n вызывает `CreateWebContact` с phone
|
||||||
|
- Получает `contact_id` из CRM
|
||||||
|
|
||||||
|
### Шаг 2: Поиск/создание пользователя в PostgreSQL
|
||||||
|
SQL запрос (аналогично Telegram):
|
||||||
|
```sql
|
||||||
|
WITH existing AS (
|
||||||
|
SELECT u.id AS user_id, u.unified_id
|
||||||
|
FROM clpr_user_accounts ua
|
||||||
|
JOIN clpr_users u ON u.id = ua.user_id
|
||||||
|
WHERE ua.channel = 'web_form'
|
||||||
|
AND ua.channel_user_id = '{phone}'
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
create_user AS (
|
||||||
|
INSERT INTO clpr_users (unified_id, phone, created_at, updated_at)
|
||||||
|
SELECT 'usr_' || gen_random_uuid()::text, '{phone}', now(), now()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM existing)
|
||||||
|
RETURNING id AS user_id, unified_id
|
||||||
|
),
|
||||||
|
final_user AS (
|
||||||
|
SELECT * FROM existing
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM create_user
|
||||||
|
),
|
||||||
|
create_account AS (
|
||||||
|
INSERT INTO clpr_user_accounts(user_id, channel, channel_user_id)
|
||||||
|
SELECT
|
||||||
|
(SELECT user_id FROM final_user),
|
||||||
|
'web_form',
|
||||||
|
'{phone}'
|
||||||
|
ON CONFLICT (channel, channel_user_id) DO NOTHING
|
||||||
|
)
|
||||||
|
SELECT unified_id FROM final_user LIMIT 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 3: Создание/обновление заявки
|
||||||
|
- n8n создает/обновляет запись в `clpr_claims`
|
||||||
|
- **ВАЖНО:** заполняет `unified_id` из результата шага 2
|
||||||
|
- Сохраняет `phone` в `payload->>'phone'`
|
||||||
|
- `channel = 'web_form'`
|
||||||
|
- `status_code = 'draft'` для черновиков
|
||||||
|
|
||||||
|
### Шаг 4: Поиск черновиков
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.payload->>'claim_id' as claim_id,
|
||||||
|
c.session_token,
|
||||||
|
c.status_code,
|
||||||
|
c.payload,
|
||||||
|
c.created_at,
|
||||||
|
c.updated_at
|
||||||
|
FROM clpr_claims c
|
||||||
|
WHERE c.status_code = 'draft'
|
||||||
|
AND c.channel = 'web_form'
|
||||||
|
AND c.unified_id = '{unified_id}' -- ← ПОИСК ПО unified_id!
|
||||||
|
ORDER BY c.updated_at DESC
|
||||||
|
LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проблема в текущей реализации
|
||||||
|
|
||||||
|
**Текущее состояние:**
|
||||||
|
- В `clpr_claims` поле `unified_id` **ПУСТОЕ** для всех черновиков web_form
|
||||||
|
- Поиск идет по `payload->>'phone'` или `session_token`, что не надежно
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
- n8n должен заполнять `unified_id` при создании/обновлении заявки
|
||||||
|
- Backend должен искать черновики по `unified_id`, а не по phone/session_id
|
||||||
|
|
||||||
|
## Связи между таблицами
|
||||||
|
|
||||||
|
```
|
||||||
|
clpr_users (unified_id)
|
||||||
|
↑
|
||||||
|
| (через unified_id)
|
||||||
|
|
|
||||||
|
clpr_claims (unified_id)
|
||||||
|
|
|
||||||
|
| (через user_id)
|
||||||
|
↓
|
||||||
|
clpr_user_accounts (user_id → clpr_users.id)
|
||||||
|
|
|
||||||
|
| (channel='web_form', channel_user_id=phone)
|
||||||
|
↓
|
||||||
|
clpr_claims (payload->>'phone')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Для Telegram (для сравнения)
|
||||||
|
|
||||||
|
```
|
||||||
|
clpr_users (unified_id)
|
||||||
|
↑
|
||||||
|
| (через unified_id)
|
||||||
|
|
|
||||||
|
clpr_users_tg (unified_id)
|
||||||
|
|
|
||||||
|
| (telegram_id)
|
||||||
|
↓
|
||||||
|
clpr_user_accounts (channel='telegram', channel_user_id=telegram_id)
|
||||||
|
|
|
||||||
|
| (user_id)
|
||||||
|
↓
|
||||||
|
clpr_users (id)
|
||||||
|
```
|
||||||
|
|
||||||
285
docs/FIXED_SQL_QUERY.md
Normal file
285
docs/FIXED_SQL_QUERY.md
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
# Исправленный SQL запрос для сохранения заявки
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
Оригинальный SQL запрос использует `$2::uuid`, но передается строка `"CLM-2025-11-18-GEQ3KL"`, что вызывает ошибку:
|
||||||
|
```
|
||||||
|
invalid input syntax for type uuid: "CLM-2025-11-18-GEQ3KL"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Изменить SQL запрос так, чтобы он:
|
||||||
|
1. Принимал `claim_id` как строку (VARCHAR)
|
||||||
|
2. Искал запись в `clpr_claims` по `payload->>'claim_id'` или создавал новую
|
||||||
|
3. Использовал найденный UUID для дальнейших операций
|
||||||
|
|
||||||
|
## Исправленный SQL запрос
|
||||||
|
|
||||||
|
```sql
|
||||||
|
WITH partial AS (
|
||||||
|
SELECT $1::jsonb AS p, $2::text AS claim_id_str
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Сначала находим существующую запись или создаем новую
|
||||||
|
claim_lookup AS (
|
||||||
|
SELECT
|
||||||
|
COALESCE(
|
||||||
|
(SELECT id FROM clpr_claims WHERE payload->>'claim_id' = partial.claim_id_str LIMIT 1),
|
||||||
|
gen_random_uuid()
|
||||||
|
) AS claim_uuid
|
||||||
|
FROM partial
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Если записи нет, создаем её
|
||||||
|
claim_created AS (
|
||||||
|
INSERT INTO clpr_claims (
|
||||||
|
id,
|
||||||
|
session_token,
|
||||||
|
channel,
|
||||||
|
type_code,
|
||||||
|
status_code,
|
||||||
|
payload,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
expires_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
claim_lookup.claim_uuid,
|
||||||
|
COALESCE(partial.p->>'session_id', 'sess-' || gen_random_uuid()::text),
|
||||||
|
'web_form',
|
||||||
|
COALESCE(partial.p->>'type_code', 'consumer'),
|
||||||
|
'draft',
|
||||||
|
jsonb_build_object(
|
||||||
|
'claim_id', partial.claim_id_str,
|
||||||
|
'answers',
|
||||||
|
CASE
|
||||||
|
-- В корне
|
||||||
|
WHEN partial.p->>'wizard_answers' IS NOT NULL
|
||||||
|
THEN (partial.p->>'wizard_answers')::jsonb
|
||||||
|
-- В edit_fields_raw.body
|
||||||
|
WHEN partial.p->'edit_fields_raw'->'body'->>'wizard_answers' IS NOT NULL
|
||||||
|
THEN (partial.p->'edit_fields_raw'->'body'->>'wizard_answers')::jsonb
|
||||||
|
-- В edit_fields_parsed.body
|
||||||
|
WHEN partial.p->'edit_fields_parsed'->'body'->>'wizard_answers' IS NOT NULL
|
||||||
|
THEN (partial.p->'edit_fields_parsed'->'body'->>'wizard_answers')::jsonb
|
||||||
|
-- Если уже объект
|
||||||
|
WHEN partial.p->'wizard_answers' IS NOT NULL AND jsonb_typeof(partial.p->'wizard_answers') = 'object'
|
||||||
|
THEN partial.p->'wizard_answers'
|
||||||
|
ELSE '{}'::jsonb
|
||||||
|
END,
|
||||||
|
'documents_meta', COALESCE(partial.p->'documents_meta', '[]'::jsonb),
|
||||||
|
'wizard_plan',
|
||||||
|
CASE
|
||||||
|
-- В корне
|
||||||
|
WHEN partial.p->>'wizard_plan' IS NOT NULL
|
||||||
|
THEN (partial.p->>'wizard_plan')::jsonb
|
||||||
|
-- В edit_fields_raw.body
|
||||||
|
WHEN partial.p->'edit_fields_raw'->'body'->>'wizard_plan' IS NOT NULL
|
||||||
|
THEN (partial.p->'edit_fields_raw'->'body'->>'wizard_plan')::jsonb
|
||||||
|
-- В edit_fields_parsed.body
|
||||||
|
WHEN partial.p->'edit_fields_parsed'->'body'->>'wizard_plan' IS NOT NULL
|
||||||
|
THEN (partial.p->'edit_fields_parsed'->'body'->>'wizard_plan')::jsonb
|
||||||
|
-- Если уже объект
|
||||||
|
WHEN partial.p->'wizard_plan' IS NOT NULL AND jsonb_typeof(partial.p->'wizard_plan') = 'object'
|
||||||
|
THEN partial.p->'wizard_plan'
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
),
|
||||||
|
now(),
|
||||||
|
now(),
|
||||||
|
now() + interval '14 days'
|
||||||
|
FROM partial, claim_lookup
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM clpr_claims WHERE id = claim_lookup.claim_uuid
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Получаем финальный UUID (из существующей записи или только что созданной)
|
||||||
|
claim_final AS (
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN EXISTS (SELECT 1 FROM claim_created)
|
||||||
|
THEN (SELECT id FROM claim_created LIMIT 1)
|
||||||
|
ELSE claim_lookup.claim_uuid
|
||||||
|
END AS claim_uuid
|
||||||
|
FROM claim_lookup
|
||||||
|
),
|
||||||
|
|
||||||
|
inserted_docs AS (
|
||||||
|
INSERT INTO clpr_claim_documents
|
||||||
|
(claim_id, field_name, file_id, uploaded_at, file_name, original_file_name)
|
||||||
|
SELECT
|
||||||
|
claim_final.claim_uuid::text AS claim_id,
|
||||||
|
doc.field_name,
|
||||||
|
doc.file_id,
|
||||||
|
(doc.uploaded_at)::timestamptz AS uploaded_at,
|
||||||
|
doc.file_name,
|
||||||
|
doc.original_file_name
|
||||||
|
FROM partial, claim_final
|
||||||
|
CROSS JOIN LATERAL jsonb_to_recordset(
|
||||||
|
COALESCE(partial.p->'documents_meta','[]'::jsonb)
|
||||||
|
) AS doc(
|
||||||
|
field_name text,
|
||||||
|
file_id text,
|
||||||
|
file_name text,
|
||||||
|
original_file_name text,
|
||||||
|
uploaded_at text
|
||||||
|
)
|
||||||
|
ON CONFLICT (claim_id, field_name) DO UPDATE
|
||||||
|
SET file_id = EXCLUDED.file_id,
|
||||||
|
uploaded_at = EXCLUDED.uploaded_at,
|
||||||
|
file_name = EXCLUDED.file_name,
|
||||||
|
original_file_name = EXCLUDED.original_file_name
|
||||||
|
RETURNING id, claim_id, field_name, file_id
|
||||||
|
),
|
||||||
|
|
||||||
|
existing AS (
|
||||||
|
SELECT c.id, c.payload
|
||||||
|
FROM clpr_claims c, claim_final
|
||||||
|
WHERE c.id = claim_final.claim_uuid
|
||||||
|
FOR UPDATE
|
||||||
|
),
|
||||||
|
|
||||||
|
old AS (
|
||||||
|
SELECT
|
||||||
|
COALESCE(
|
||||||
|
(SELECT payload FROM existing LIMIT 1),
|
||||||
|
'{}'::jsonb
|
||||||
|
) AS old_payload
|
||||||
|
FROM claim_final
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Парсим wizard_answers из строки в JSON объект
|
||||||
|
-- Ищем в разных местах: корень, edit_fields_raw.body, edit_fields_parsed.body
|
||||||
|
wizard_answers_parsed AS (
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
-- В корне payload_partial_json
|
||||||
|
WHEN partial.p->>'wizard_answers' IS NOT NULL
|
||||||
|
THEN (partial.p->>'wizard_answers')::jsonb
|
||||||
|
-- В edit_fields_raw.body.wizard_answers
|
||||||
|
WHEN partial.p->'edit_fields_raw'->'body'->>'wizard_answers' IS NOT NULL
|
||||||
|
THEN (partial.p->'edit_fields_raw'->'body'->>'wizard_answers')::jsonb
|
||||||
|
-- В edit_fields_parsed.body.wizard_answers
|
||||||
|
WHEN partial.p->'edit_fields_parsed'->'body'->>'wizard_answers' IS NOT NULL
|
||||||
|
THEN (partial.p->'edit_fields_parsed'->'body'->>'wizard_answers')::jsonb
|
||||||
|
-- Если уже объект (не строка)
|
||||||
|
WHEN partial.p->'wizard_answers' IS NOT NULL AND jsonb_typeof(partial.p->'wizard_answers') = 'object'
|
||||||
|
THEN partial.p->'wizard_answers'
|
||||||
|
ELSE '{}'::jsonb
|
||||||
|
END AS answers
|
||||||
|
FROM partial
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Парсим wizard_plan из строки в JSON объект
|
||||||
|
-- Ищем в разных местах: корень, edit_fields_raw.body, edit_fields_parsed.body
|
||||||
|
wizard_plan_parsed AS (
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
-- В корне payload_partial_json
|
||||||
|
WHEN partial.p->>'wizard_plan' IS NOT NULL
|
||||||
|
THEN (partial.p->>'wizard_plan')::jsonb
|
||||||
|
-- В edit_fields_raw.body.wizard_plan
|
||||||
|
WHEN partial.p->'edit_fields_raw'->'body'->>'wizard_plan' IS NOT NULL
|
||||||
|
THEN (partial.p->'edit_fields_raw'->'body'->>'wizard_plan')::jsonb
|
||||||
|
-- В edit_fields_parsed.body.wizard_plan
|
||||||
|
WHEN partial.p->'edit_fields_parsed'->'body'->>'wizard_plan' IS NOT NULL
|
||||||
|
THEN (partial.p->'edit_fields_parsed'->'body'->>'wizard_plan')::jsonb
|
||||||
|
-- Если уже объект (не строка)
|
||||||
|
WHEN partial.p->'wizard_plan' IS NOT NULL AND jsonb_typeof(partial.p->'wizard_plan') = 'object'
|
||||||
|
THEN partial.p->'wizard_plan'
|
||||||
|
ELSE NULL
|
||||||
|
END AS wizard_plan
|
||||||
|
FROM partial
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Объединяем documents_meta без дублирования (используем новый, если есть)
|
||||||
|
docs_merged AS (
|
||||||
|
SELECT
|
||||||
|
COALESCE(
|
||||||
|
NULLIF(partial.p->'documents_meta', 'null'::jsonb),
|
||||||
|
old.old_payload->'documents_meta',
|
||||||
|
'[]'::jsonb
|
||||||
|
) AS documents_meta
|
||||||
|
FROM old, partial
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Формируем чистый payload (без лишних полей)
|
||||||
|
clean_payload AS (
|
||||||
|
SELECT jsonb_build_object(
|
||||||
|
'claim_id', partial.claim_id_str,
|
||||||
|
'answers', (SELECT answers FROM wizard_answers_parsed LIMIT 1),
|
||||||
|
'documents_meta', (SELECT documents_meta FROM docs_merged LIMIT 1),
|
||||||
|
'wizard_plan', (SELECT wizard_plan FROM wizard_plan_parsed LIMIT 1)
|
||||||
|
) AS clean
|
||||||
|
FROM partial
|
||||||
|
),
|
||||||
|
|
||||||
|
upd AS (
|
||||||
|
UPDATE clpr_claims c
|
||||||
|
SET
|
||||||
|
payload = (
|
||||||
|
-- Сохраняем только нужные поля из старого payload
|
||||||
|
COALESCE(old.old_payload, '{}'::jsonb) - 'answers' - 'documents_meta' - 'wizard_plan' - 'wizard_answers' - 'form_data' - 'edit_fields_raw' - 'edit_fields_parsed'
|
||||||
|
-- Добавляем чистый payload
|
||||||
|
|| (SELECT clean FROM clean_payload LIMIT 1)
|
||||||
|
),
|
||||||
|
status_code = CASE
|
||||||
|
WHEN ( (SELECT answers->>'docs_exist' FROM wizard_answers_parsed LIMIT 1) = 'true' )
|
||||||
|
THEN 'in_work'
|
||||||
|
ELSE COALESCE(c.status_code, 'draft')
|
||||||
|
END,
|
||||||
|
updated_at = now(),
|
||||||
|
expires_at = now() + interval '14 days'
|
||||||
|
FROM partial, old, claim_final, clean_payload
|
||||||
|
WHERE c.id = claim_final.claim_uuid
|
||||||
|
RETURNING c.id, c.status_code, c.payload
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
(SELECT jsonb_build_object(
|
||||||
|
'claim_id', u.id::text,
|
||||||
|
'claim_id_str', (u.payload->>'claim_id'),
|
||||||
|
'status_code', u.status_code,
|
||||||
|
'payload', u.payload
|
||||||
|
) FROM upd u) AS claim,
|
||||||
|
(SELECT jsonb_agg(jsonb_build_object(
|
||||||
|
'id', id,
|
||||||
|
'field_name', field_name,
|
||||||
|
'file_id', file_id
|
||||||
|
)) FROM inserted_docs) AS documents;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Изменения
|
||||||
|
|
||||||
|
1. **`claim_id_str` вместо `uuid`**: `$2::text AS claim_id_str` вместо `$2::uuid AS cid`
|
||||||
|
2. **`claim_lookup` CTE**: Находит существующую запись по `payload->>'claim_id'` или генерирует новый UUID
|
||||||
|
3. **`claim_created` CTE**: Создает новую запись, если её нет
|
||||||
|
4. **Использование `claim_uuid`**: Во всех местах используется UUID из `claim_lookup`, а не строка
|
||||||
|
5. **`claim_id` в `clpr_claim_documents`**: Преобразуется в строку `claim_uuid::text`, т.к. в таблице `claim_id` имеет тип `character varying`
|
||||||
|
|
||||||
|
## Параметры запроса
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// В n8n PostgreSQL Node
|
||||||
|
Parameters:
|
||||||
|
$1 = JSONB с данными (payload_partial_json)
|
||||||
|
$2 = TEXT с claim_id ("CLM-2025-11-18-GEQ3KL")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Альтернативное решение (если не хотите менять SQL)
|
||||||
|
|
||||||
|
Если не хотите менять SQL запрос, можно изменить логику в n8n:
|
||||||
|
|
||||||
|
1. **Перед SQL запросом** добавить Code Node, который:
|
||||||
|
- Находит запись в `clpr_claims` по `payload->>'claim_id'`
|
||||||
|
- Если найдена - использует её `id` (UUID)
|
||||||
|
- Если не найдена - создает новую запись и возвращает её `id`
|
||||||
|
|
||||||
|
2. **Передавать UUID** вместо строки `claim_id` в SQL запрос
|
||||||
|
|
||||||
|
Но первый вариант (изменение SQL) более надежный и правильный.
|
||||||
|
|
||||||
38
docs/N8N_CODE_NODE_RESPONSE.js
Normal file
38
docs/N8N_CODE_NODE_RESPONSE.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// ========================================
|
||||||
|
// Code Node: Формирование Response для фронта
|
||||||
|
// (перед финальной Response нодой)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Получаем данные из предыдущих шагов
|
||||||
|
const claimResult = $node["CreateWebContact"].json.result;
|
||||||
|
const sessionData = JSON.parse($('Code in JavaScript1').first().json.redis_value);
|
||||||
|
const userData = $node["user_get"].json; // ← Данные из PostgreSQL: Find or Create User
|
||||||
|
|
||||||
|
// Формируем ответ в формате, который ожидает фронт
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
claim_id: sessionData.claim_id,
|
||||||
|
contact_id: sessionData.contact_id,
|
||||||
|
project_id: sessionData.project_id,
|
||||||
|
|
||||||
|
// Unified ID из PostgreSQL (обязательно!)
|
||||||
|
unified_id: userData.unified_id || userData.unified_id, // из ноды user_get
|
||||||
|
|
||||||
|
// Данные заявки
|
||||||
|
ticket_id: claimResult.ticket_id,
|
||||||
|
ticket_number: claimResult.ticket_number,
|
||||||
|
title: claimResult.title,
|
||||||
|
category: claimResult.category,
|
||||||
|
status: claimResult.status,
|
||||||
|
|
||||||
|
// Метаданные
|
||||||
|
event_type: sessionData.event_type,
|
||||||
|
current_step: sessionData.current_step,
|
||||||
|
updated_at: sessionData.updated_at,
|
||||||
|
|
||||||
|
// Дополнительно
|
||||||
|
is_new_contact: claimResult.is_new_contact || false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
47
docs/N8N_CODE_NODE_RESPONSE_SAFE.js
Normal file
47
docs/N8N_CODE_NODE_RESPONSE_SAFE.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// ========================================
|
||||||
|
// Code Node: Формирование Response для фронта (безопасная версия с проверками)
|
||||||
|
// (перед финальной Response нодой)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// Получаем данные из предыдущих шагов
|
||||||
|
const claimResult = $node["CreateWebContact"]?.json?.result || {};
|
||||||
|
const sessionDataItem = $('Code in JavaScript1')?.first();
|
||||||
|
const sessionData = sessionDataItem?.json?.redis_value
|
||||||
|
? JSON.parse(sessionDataItem.json.redis_value)
|
||||||
|
: {};
|
||||||
|
const userData = $node["user_get"]?.json || {}; // ← Данные из PostgreSQL: Find or Create User
|
||||||
|
|
||||||
|
// Проверяем наличие unified_id (критически важно!)
|
||||||
|
if (!userData.unified_id) {
|
||||||
|
console.error('❌ ОШИБКА: unified_id не получен из ноды user_get!');
|
||||||
|
// Можно либо выбросить ошибку, либо продолжить без unified_id (не рекомендуется)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем ответ в формате, который ожидает фронт
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
claim_id: sessionData.claim_id || claimResult.claim_id,
|
||||||
|
contact_id: sessionData.contact_id || claimResult.contact_id,
|
||||||
|
project_id: sessionData.project_id,
|
||||||
|
|
||||||
|
// Unified ID из PostgreSQL (обязательно!)
|
||||||
|
unified_id: userData.unified_id, // из ноды user_get (PostgreSQL: Find or Create User)
|
||||||
|
|
||||||
|
// Данные заявки
|
||||||
|
ticket_id: claimResult.ticket_id,
|
||||||
|
ticket_number: claimResult.ticket_number,
|
||||||
|
title: claimResult.title,
|
||||||
|
category: claimResult.category,
|
||||||
|
status: claimResult.status,
|
||||||
|
|
||||||
|
// Метаданные
|
||||||
|
event_type: sessionData.event_type,
|
||||||
|
current_step: sessionData.current_step || 1,
|
||||||
|
updated_at: sessionData.updated_at || new Date().toISOString(),
|
||||||
|
|
||||||
|
// Дополнительно
|
||||||
|
is_new_contact: claimResult.is_new_contact || false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
94
docs/N8N_RESPONSE_FORMAT.md
Normal file
94
docs/N8N_RESPONSE_FORMAT.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# Формат ответа n8n после проверки телефона
|
||||||
|
|
||||||
|
## Текущий формат (неполный)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"result": {
|
||||||
|
"claim_id": "CLM-2025-11-19-7O55SP",
|
||||||
|
"contact_id": "398644",
|
||||||
|
"event_type": null,
|
||||||
|
"current_step": 1,
|
||||||
|
"updated_at": "2025-11-19T15:15:07.323Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Требуемый формат (с unified_id)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"result": {
|
||||||
|
"claim_id": "CLM-2025-11-19-7O55SP",
|
||||||
|
"contact_id": "398644",
|
||||||
|
"unified_id": "usr_90599ff2-ac79-4236-b950-0df85395096c", // ← ДОБАВИТЬ!
|
||||||
|
"event_type": null,
|
||||||
|
"current_step": 1,
|
||||||
|
"updated_at": "2025-11-19T15:15:07.323Z",
|
||||||
|
"is_new_contact": false // опционально
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Где добавить unified_id в n8n workflow
|
||||||
|
|
||||||
|
### Шаг 1: После CreateWebContact
|
||||||
|
- Получен `contact_id` из CRM
|
||||||
|
- Есть `phone` из запроса
|
||||||
|
|
||||||
|
### Шаг 2: PostgreSQL Node - Find or Create User
|
||||||
|
- Выполнить SQL запрос из `SQL_FIND_OR_CREATE_USER_WEB_FORM.sql`
|
||||||
|
- Параметр: `$1 = {{$json.phone}}` (нормализованный телефон)
|
||||||
|
- Результат: `unified_id` и `user_id`
|
||||||
|
|
||||||
|
### Шаг 3: Response Node или Code Node
|
||||||
|
Вернуть ответ с unified_id:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
claim_id: $('CreateWebContact').item.json.claim_id || $('GenerateClaimId').item.json.claim_id,
|
||||||
|
contact_id: $('CreateWebContact').item.json.contact_id,
|
||||||
|
unified_id: $('PostgreSQL_FindOrCreateUser').item.json.unified_id, // ← ВАЖНО!
|
||||||
|
event_type: null,
|
||||||
|
current_step: 1,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
is_new_contact: $('CreateWebContact').item.json.is_new_contact || false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Важно!
|
||||||
|
|
||||||
|
1. **unified_id обязателен** - frontend использует его для поиска черновиков
|
||||||
|
2. **Формат unified_id**: `usr_{UUID}` (например, `usr_90599ff2-ac79-4236-b950-0df85395096c`)
|
||||||
|
3. **Если unified_id отсутствует** - frontend не сможет найти черновики пользователя
|
||||||
|
4. **При создании/обновлении черновика** - обязательно заполнять `clpr_claims.unified_id = unified_id`
|
||||||
|
|
||||||
|
## Проверка в frontend
|
||||||
|
|
||||||
|
Frontend уже готов принимать unified_id:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Step1Phone.tsx, строка 132
|
||||||
|
updateFormData({
|
||||||
|
phone,
|
||||||
|
smsCode: code,
|
||||||
|
contact_id: result.contact_id,
|
||||||
|
unified_id: result.unified_id, // ✅ Уже ожидается!
|
||||||
|
claim_id: result.claim_id,
|
||||||
|
is_new_contact: result.is_new_contact
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Пример полного workflow в n8n
|
||||||
|
|
||||||
|
1. **Webhook** → получает `{phone, session_id, form_id}`
|
||||||
|
2. **CreateWebContact** → создает/находит контакт в CRM → возвращает `contact_id`
|
||||||
|
3. **GenerateClaimId** → генерирует `claim_id` (если нужно)
|
||||||
|
4. **PostgreSQL: Find or Create User** → выполняет SQL запрос → возвращает `unified_id`
|
||||||
|
5. **Response** → возвращает полный ответ с `unified_id`
|
||||||
|
|
||||||
144
docs/N8N_RESPONSE_WITH_UNIFIED_ID.md
Normal file
144
docs/N8N_RESPONSE_WITH_UNIFIED_ID.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# Обновление Response Node в n8n: Добавление unified_id
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
В текущем Response Node отсутствует `unified_id`, который необходим для поиска черновиков на фронтенде.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
### Шаг 1: Убедитесь, что есть нода `user_get`
|
||||||
|
Это PostgreSQL нода, которая выполняет SQL запрос из `SQL_FIND_OR_CREATE_USER_WEB_FORM.sql`.
|
||||||
|
|
||||||
|
**Настройки ноды:**
|
||||||
|
- **Name**: `user_get` (или другое имя, но должно совпадать в коде)
|
||||||
|
- **Operation**: Execute Query
|
||||||
|
- **Query**: SQL из `SQL_FIND_OR_CREATE_USER_WEB_FORM.sql`
|
||||||
|
- **Parameters**: `$1 = {{$json.phone}}` (нормализованный телефон)
|
||||||
|
|
||||||
|
**Результат ноды:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"unified_id": "usr_90599ff2-ac79-4236-b950-0df85395096c",
|
||||||
|
"user_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 2: Обновите Code Node перед Response
|
||||||
|
|
||||||
|
**Вариант 1: Простая версия**
|
||||||
|
```javascript
|
||||||
|
// ========================================
|
||||||
|
// Code Node: Формирование Response для фронта
|
||||||
|
// (перед финальной Response нодой)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const claimResult = $node["CreateWebContact"].json.result;
|
||||||
|
const sessionData = JSON.parse($('Code in JavaScript1').first().json.redis_value);
|
||||||
|
const userData = $node["user_get"].json; // ← Данные из PostgreSQL
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
claim_id: sessionData.claim_id,
|
||||||
|
contact_id: sessionData.contact_id,
|
||||||
|
project_id: sessionData.project_id,
|
||||||
|
|
||||||
|
// Unified ID из PostgreSQL (обязательно!)
|
||||||
|
unified_id: userData.unified_id, // ← ДОБАВЛЕНО!
|
||||||
|
|
||||||
|
ticket_id: claimResult.ticket_id,
|
||||||
|
ticket_number: claimResult.ticket_number,
|
||||||
|
title: claimResult.title,
|
||||||
|
category: claimResult.category,
|
||||||
|
status: claimResult.status,
|
||||||
|
|
||||||
|
event_type: sessionData.event_type,
|
||||||
|
current_step: sessionData.current_step,
|
||||||
|
updated_at: sessionData.updated_at,
|
||||||
|
|
||||||
|
is_new_contact: claimResult.is_new_contact || false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вариант 2: Безопасная версия с проверками**
|
||||||
|
```javascript
|
||||||
|
// ========================================
|
||||||
|
// Code Node: Формирование Response для фронта (безопасная версия)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const claimResult = $node["CreateWebContact"]?.json?.result || {};
|
||||||
|
const sessionDataItem = $('Code in JavaScript1')?.first();
|
||||||
|
const sessionData = sessionDataItem?.json?.redis_value
|
||||||
|
? JSON.parse(sessionDataItem.json.redis_value)
|
||||||
|
: {};
|
||||||
|
const userData = $node["user_get"]?.json || {}; // ← Данные из PostgreSQL
|
||||||
|
|
||||||
|
// Проверяем наличие unified_id (критически важно!)
|
||||||
|
if (!userData.unified_id) {
|
||||||
|
console.error('❌ ОШИБКА: unified_id не получен из ноды user_get!');
|
||||||
|
// Можно либо выбросить ошибку, либо продолжить без unified_id (не рекомендуется)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
claim_id: sessionData.claim_id || claimResult.claim_id,
|
||||||
|
contact_id: sessionData.contact_id || claimResult.contact_id,
|
||||||
|
project_id: sessionData.project_id,
|
||||||
|
|
||||||
|
// Unified ID из PostgreSQL (обязательно!)
|
||||||
|
unified_id: userData.unified_id, // ← ДОБАВЛЕНО!
|
||||||
|
|
||||||
|
ticket_id: claimResult.ticket_id,
|
||||||
|
ticket_number: claimResult.ticket_number,
|
||||||
|
title: claimResult.title,
|
||||||
|
category: claimResult.category,
|
||||||
|
status: claimResult.status,
|
||||||
|
|
||||||
|
event_type: sessionData.event_type,
|
||||||
|
current_step: sessionData.current_step || 1,
|
||||||
|
updated_at: sessionData.updated_at || new Date().toISOString(),
|
||||||
|
|
||||||
|
is_new_contact: claimResult.is_new_contact || false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Порядок нод в workflow
|
||||||
|
|
||||||
|
1. **Webhook** → получает `{phone, session_id, form_id}`
|
||||||
|
2. **Code in JavaScript1** → получает данные из Redis
|
||||||
|
3. **CreateWebContact** → создает/находит контакт в CRM
|
||||||
|
4. **user_get** (PostgreSQL) → находит/создает пользователя → возвращает `unified_id`
|
||||||
|
5. **Code Node** (этот код) → формирует финальный ответ
|
||||||
|
6. **Response** → возвращает ответ фронтенду
|
||||||
|
|
||||||
|
## Важно!
|
||||||
|
|
||||||
|
1. **Имя ноды**: Убедитесь, что имя ноды PostgreSQL совпадает с `$node["user_get"]` в коде
|
||||||
|
2. **unified_id обязателен**: Без него фронтенд не сможет найти черновики
|
||||||
|
3. **Проверка**: Добавьте проверку на наличие `unified_id` перед возвратом ответа
|
||||||
|
|
||||||
|
## Ожидаемый формат ответа
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"result": {
|
||||||
|
"claim_id": "CLM-2025-11-19-7O55SP",
|
||||||
|
"contact_id": "398644",
|
||||||
|
"project_id": "12345",
|
||||||
|
"unified_id": "usr_90599ff2-ac79-4236-b950-0df85395096c", // ← ОБЯЗАТЕЛЬНО!
|
||||||
|
"ticket_id": "45678",
|
||||||
|
"ticket_number": "HD001234",
|
||||||
|
"title": "Заявка",
|
||||||
|
"category": "Категория",
|
||||||
|
"status": "Новая",
|
||||||
|
"event_type": null,
|
||||||
|
"current_step": 1,
|
||||||
|
"updated_at": "2025-11-19T15:15:07.323Z",
|
||||||
|
"is_new_contact": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
133
docs/N8N_USER_CREATION_INSTRUCTIONS.md
Normal file
133
docs/N8N_USER_CREATION_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# Инструкция для n8n: Создание/поиск пользователя web_form
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
После создания контакта в CRM через `CreateWebContact`, нужно найти или создать пользователя в PostgreSQL и получить `unified_id` для связи с черновиками.
|
||||||
|
|
||||||
|
## Шаги в n8n workflow
|
||||||
|
|
||||||
|
### 1. После CreateWebContact
|
||||||
|
- Получен `contact_id` из CRM
|
||||||
|
- Есть `phone` из запроса
|
||||||
|
|
||||||
|
### 2. PostgreSQL Node: Find or Create User
|
||||||
|
|
||||||
|
**Настройки:**
|
||||||
|
- **Operation**: Execute Query
|
||||||
|
- **Query**: Использовать запрос из `SQL_FIND_OR_CREATE_USER_WEB_FORM.sql`
|
||||||
|
- **Parameters**:
|
||||||
|
- `$1` = `{{$json.phone}}` (или `{{$('CreateWebContact').item.json.phone}}`)
|
||||||
|
|
||||||
|
**Запрос:**
|
||||||
|
```sql
|
||||||
|
WITH existing AS (
|
||||||
|
SELECT u.id AS user_id, u.unified_id
|
||||||
|
FROM clpr_user_accounts ua
|
||||||
|
JOIN clpr_users u ON u.id = ua.user_id
|
||||||
|
WHERE ua.channel = 'web_form'
|
||||||
|
AND ua.channel_user_id = $1
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
create_user AS (
|
||||||
|
INSERT INTO clpr_users (unified_id, phone, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
'usr_' || gen_random_uuid()::text,
|
||||||
|
$1,
|
||||||
|
now(),
|
||||||
|
now()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM existing)
|
||||||
|
RETURNING id AS user_id, unified_id
|
||||||
|
),
|
||||||
|
final_user AS (
|
||||||
|
SELECT * FROM existing
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM create_user
|
||||||
|
),
|
||||||
|
update_unified AS (
|
||||||
|
UPDATE clpr_users
|
||||||
|
SET unified_id = COALESCE(
|
||||||
|
unified_id,
|
||||||
|
'usr_' || gen_random_uuid()::text
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = (SELECT user_id FROM final_user LIMIT 1)
|
||||||
|
AND unified_id IS NULL
|
||||||
|
RETURNING id AS user_id, unified_id
|
||||||
|
),
|
||||||
|
final_unified_id AS (
|
||||||
|
SELECT unified_id FROM update_unified
|
||||||
|
UNION ALL
|
||||||
|
SELECT unified_id FROM final_user
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM update_unified)
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
create_account AS (
|
||||||
|
INSERT INTO clpr_user_accounts(user_id, channel, channel_user_id)
|
||||||
|
SELECT
|
||||||
|
(SELECT user_id FROM final_user LIMIT 1),
|
||||||
|
'web_form',
|
||||||
|
$1
|
||||||
|
ON CONFLICT (channel, channel_user_id) DO UPDATE
|
||||||
|
SET user_id = EXCLUDED.user_id
|
||||||
|
RETURNING user_id, channel, channel_user_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT unified_id FROM final_unified_id LIMIT 1) AS unified_id,
|
||||||
|
(SELECT user_id FROM final_user LIMIT 1) AS user_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Результат:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"unified_id": "usr_b2fd7f73-c238-4fde-949b-c404cded12f3",
|
||||||
|
"user_id": 106
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Сохранение unified_id в Redis
|
||||||
|
|
||||||
|
**Set Node (Redis)** или **Code Node**:
|
||||||
|
```javascript
|
||||||
|
const unified_id = $input.item.json.unified_id;
|
||||||
|
const claim_id = $('CreateWebContact').item.json.claim_id; // или откуда берете claim_id
|
||||||
|
|
||||||
|
// Сохранить в Redis
|
||||||
|
await redis.set(`claim:${claim_id}`, JSON.stringify({
|
||||||
|
...existing_data,
|
||||||
|
unified_id: unified_id
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Возврат unified_id в ответе frontend
|
||||||
|
|
||||||
|
**Response Node** или в **Code Node** перед возвратом:
|
||||||
|
```javascript
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
contact_id: $('CreateWebContact').item.json.contact_id,
|
||||||
|
claim_id: $('CreateWebContact').item.json.claim_id,
|
||||||
|
unified_id: $('PostgreSQL').item.json.unified_id, // ← ВАЖНО!
|
||||||
|
is_new_contact: $('CreateWebContact').item.json.is_new_contact
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Важно!
|
||||||
|
|
||||||
|
1. **unified_id должен быть в ответе** - frontend сохраняет его в `formData.unified_id`
|
||||||
|
2. **При создании/обновлении черновика** - заполнять `clpr_claims.unified_id = unified_id`
|
||||||
|
3. **Формат телефона**: `79991234567` (11 цифр, начинается с 7)
|
||||||
|
|
||||||
|
## Проверка работы
|
||||||
|
|
||||||
|
После выполнения запроса проверьте:
|
||||||
|
```sql
|
||||||
|
SELECT u.unified_id, u.phone, ua.channel, ua.channel_user_id
|
||||||
|
FROM clpr_users u
|
||||||
|
JOIN clpr_user_accounts ua ON u.id = ua.user_id
|
||||||
|
WHERE ua.channel = 'web_form'
|
||||||
|
AND ua.channel_user_id = '79991234567';
|
||||||
|
```
|
||||||
|
|
||||||
|
Должна быть запись с `unified_id` в формате `usr_...`.
|
||||||
|
|
||||||
431
docs/PERSONAL_CABINET_ARCHITECTURE.md
Normal file
431
docs/PERSONAL_CABINET_ARCHITECTURE.md
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
# Архитектура личного кабинета и возобновления заполнения формы
|
||||||
|
|
||||||
|
## Сценарии использования
|
||||||
|
|
||||||
|
### 1. Пользователь начинает заполнять форму
|
||||||
|
```
|
||||||
|
1. Вводит телефон → SMS верификация
|
||||||
|
2. Заполняет шаг 1 (полис)
|
||||||
|
3. Заполняет шаг 2 (визард)
|
||||||
|
4. Закрывает браузер (не завершил)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Пользователь возвращается через час/день/неделю
|
||||||
|
```
|
||||||
|
1. Заходит в личный кабинет
|
||||||
|
2. Видит список незавершенных заявок
|
||||||
|
3. Нажимает "Продолжить заполнение"
|
||||||
|
4. Форма должна быстро загрузиться с сохраненным состоянием
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Варианты архитектуры
|
||||||
|
|
||||||
|
### Вариант 1: Только PostgreSQL (простой)
|
||||||
|
|
||||||
|
**Как работает:**
|
||||||
|
```
|
||||||
|
Личный кабинет → Запрос в PostgreSQL → Получение данных → Отображение формы
|
||||||
|
```
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- ✅ Просто (один источник данных)
|
||||||
|
- ✅ Всегда актуальные данные
|
||||||
|
- ✅ Нет рассинхронизации
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- ❌ Каждый раз запрос к PostgreSQL (1-10 мс)
|
||||||
|
- ❌ Нагрузка на БД при частых обращениях
|
||||||
|
|
||||||
|
**Когда использовать:**
|
||||||
|
- Небольшая нагрузка
|
||||||
|
- Простота важнее скорости
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Вариант 2: PostgreSQL + Redis кеш (рекомендую)
|
||||||
|
|
||||||
|
**Как работает:**
|
||||||
|
|
||||||
|
#### При сохранении данных:
|
||||||
|
```
|
||||||
|
1. Сохраняем в PostgreSQL (основное хранилище)
|
||||||
|
2. Сохраняем в Redis с TTL 24 часа (быстрый доступ)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### При чтении данных:
|
||||||
|
```
|
||||||
|
1. Пробуем Redis (быстро, 0.1-1 мс)
|
||||||
|
2. Если нет в кеше → PostgreSQL (1-10 мс)
|
||||||
|
3. Загружаем в Redis на 24 часа (для следующих обращений)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- ✅ Быстрый доступ (если есть в кеше)
|
||||||
|
- ✅ Fallback на PostgreSQL (если кеш пуст)
|
||||||
|
- ✅ Автоматическая очистка (TTL 24 часа)
|
||||||
|
- ✅ Lazy loading (загружаем в Redis при первом обращении)
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- ⚠️ Нужно обновлять оба хранилища
|
||||||
|
- ⚠️ Риск устаревших данных (если забыли обновить кеш)
|
||||||
|
|
||||||
|
**Когда использовать:**
|
||||||
|
- Средняя/высокая нагрузка
|
||||||
|
- Важна скорость загрузки
|
||||||
|
- Пользователи часто возвращаются к формам
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Вариант 3: Только Redis с периодической синхронизацией
|
||||||
|
|
||||||
|
**Как работает:**
|
||||||
|
```
|
||||||
|
1. Основное хранилище - Redis (TTL 7 дней)
|
||||||
|
2. Периодически синхронизируем с PostgreSQL (раз в час/день)
|
||||||
|
3. При завершении формы - сохраняем в PostgreSQL
|
||||||
|
```
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- ✅ Очень быстрый доступ
|
||||||
|
- ✅ Автоматическая очистка старых сессий
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- ❌ Риск потери данных (если Redis упал)
|
||||||
|
- ❌ Сложнее синхронизация
|
||||||
|
- ❌ Нет истории изменений
|
||||||
|
|
||||||
|
**Когда использовать:**
|
||||||
|
- Не рекомендуется (рискованно)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Рекомендуемая архитектура (Вариант 2)
|
||||||
|
|
||||||
|
### Структура данных в Redis:
|
||||||
|
|
||||||
|
**Ключ:** `claim:CLM-2025-11-18-GEQ3KL`
|
||||||
|
|
||||||
|
**Значение:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"claim_id": "CLM-2025-11-18-GEQ3KL",
|
||||||
|
"contact_id": "398523",
|
||||||
|
"phone": "72352352352",
|
||||||
|
"status": "draft",
|
||||||
|
"current_step": 3,
|
||||||
|
"payload": {
|
||||||
|
"answers": {...},
|
||||||
|
"wizard_plan": {...},
|
||||||
|
"documents_meta": [...]
|
||||||
|
},
|
||||||
|
"created_at": "2025-11-18T20:43:47.033Z",
|
||||||
|
"updated_at": "2025-11-18T20:44:59.217Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TTL:** 24 часа (86400 секунд)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Алгоритм работы:
|
||||||
|
|
||||||
|
#### 1. При сохранении данных (claimsave):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# В n8n workflow после SQL запроса
|
||||||
|
|
||||||
|
# 1. Сохраняем в PostgreSQL (уже сделано)
|
||||||
|
# 2. Сохраняем в Redis для быстрого доступа
|
||||||
|
redis_key = f"claim:{claim_id}"
|
||||||
|
redis_value = {
|
||||||
|
"claim_id": claim_id,
|
||||||
|
"contact_id": contact_id,
|
||||||
|
"phone": phone,
|
||||||
|
"status": "draft",
|
||||||
|
"current_step": current_step,
|
||||||
|
"payload": {
|
||||||
|
"answers": answers,
|
||||||
|
"wizard_plan": wizard_plan,
|
||||||
|
"documents_meta": documents_meta
|
||||||
|
},
|
||||||
|
"updated_at": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.set_json(
|
||||||
|
redis_key,
|
||||||
|
redis_value,
|
||||||
|
expire=86400 # 24 часа
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. При чтении данных (личный кабинет):
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_claim_for_resume(claim_id: str):
|
||||||
|
# 1. Пробуем Redis (быстро)
|
||||||
|
cached = await redis.get_json(f"claim:{claim_id}")
|
||||||
|
if cached:
|
||||||
|
logger.info(f"✅ Cache hit: {claim_id}")
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# 2. Если нет в кеше - из PostgreSQL
|
||||||
|
logger.info(f"🔄 Cache miss: {claim_id}, loading from PostgreSQL")
|
||||||
|
claim = await db.get_claim_by_claim_id(claim_id)
|
||||||
|
|
||||||
|
if not claim:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 3. Формируем данные для Redis
|
||||||
|
redis_data = {
|
||||||
|
"claim_id": claim_id,
|
||||||
|
"contact_id": claim.payload.get("contact_id"),
|
||||||
|
"phone": claim.payload.get("phone"),
|
||||||
|
"status": claim.status_code,
|
||||||
|
"current_step": calculate_current_step(claim.payload),
|
||||||
|
"payload": {
|
||||||
|
"answers": claim.payload.get("answers", {}),
|
||||||
|
"wizard_plan": claim.payload.get("wizard_plan"),
|
||||||
|
"documents_meta": claim.payload.get("documents_meta", [])
|
||||||
|
},
|
||||||
|
"updated_at": claim.updated_at.isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# 4. Сохраняем в Redis на 24 часа (lazy loading)
|
||||||
|
await redis.set_json(f"claim:{claim_id}", redis_data, expire=86400)
|
||||||
|
|
||||||
|
return redis_data
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. При обновлении данных:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def update_claim(claim_id: str, data: dict):
|
||||||
|
# 1. Обновляем PostgreSQL (основное хранилище)
|
||||||
|
await db.update_claim(claim_id, data)
|
||||||
|
|
||||||
|
# 2. Обновляем Redis кеш (если есть)
|
||||||
|
redis_key = f"claim:{claim_id}"
|
||||||
|
if await redis.exists(redis_key):
|
||||||
|
cached = await redis.get_json(redis_key)
|
||||||
|
if cached:
|
||||||
|
# Мерджим данные
|
||||||
|
cached.update(data)
|
||||||
|
cached["updated_at"] = datetime.now().isoformat()
|
||||||
|
await redis.set_json(redis_key, cached, expire=86400)
|
||||||
|
|
||||||
|
# Или просто удаляем кеш (при следующем чтении загрузится из PostgreSQL)
|
||||||
|
# await redis.delete(redis_key)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Стратегии TTL
|
||||||
|
|
||||||
|
### Вариант A: Фиксированный TTL (24 часа)
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- ✅ Просто
|
||||||
|
- ✅ Автоматическая очистка старых данных
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- ❌ Может истечь, даже если пользователь активен
|
||||||
|
|
||||||
|
### Вариант B: Продлеваем TTL при обращении
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- ✅ Активные заявки не истекают
|
||||||
|
- ✅ Старые заявки автоматически очищаются
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- ⚠️ Нужно продлевать TTL при каждом чтении
|
||||||
|
|
||||||
|
**Реализация:**
|
||||||
|
```python
|
||||||
|
async def get_claim_with_refresh(claim_id: str):
|
||||||
|
cached = await redis.get_json(f"claim:{claim_id}")
|
||||||
|
if cached:
|
||||||
|
# Продлеваем TTL на 24 часа
|
||||||
|
await redis.expire(f"claim:{claim_id}", 86400)
|
||||||
|
return cached
|
||||||
|
# ... загрузка из PostgreSQL
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант C: Длинный TTL для незавершенных заявок
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- ✅ Незавершенные заявки хранятся долго (7 дней)
|
||||||
|
- ✅ Завершенные заявки удаляются быстро (1 час)
|
||||||
|
|
||||||
|
**Реализация:**
|
||||||
|
```python
|
||||||
|
ttl = 604800 if status == "draft" else 3600 # 7 дней или 1 час
|
||||||
|
await redis.set_json(redis_key, data, expire=ttl)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Личный кабинет: Список незавершенных заявок
|
||||||
|
|
||||||
|
### Как получить список:
|
||||||
|
|
||||||
|
**Вариант 1: Из PostgreSQL (рекомендую)**
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
payload->>'claim_id' as claim_id,
|
||||||
|
status_code,
|
||||||
|
payload->'answers' as answers,
|
||||||
|
updated_at
|
||||||
|
FROM clpr_claims
|
||||||
|
WHERE
|
||||||
|
payload->>'claim_id' LIKE 'CLM-%'
|
||||||
|
AND status_code IN ('draft', 'in_work')
|
||||||
|
AND channel = 'web_form'
|
||||||
|
AND updated_at > NOW() - INTERVAL '30 days'
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 20;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вариант 2: Из Redis (если нужно очень быстро)**
|
||||||
|
```python
|
||||||
|
# Ищем все ключи claim:CLM-*
|
||||||
|
keys = await redis.keys("claim:CLM-*")
|
||||||
|
claims = []
|
||||||
|
for key in keys:
|
||||||
|
claim = await redis.get_json(key)
|
||||||
|
if claim and claim.get("status") in ["draft", "in_work"]:
|
||||||
|
claims.append(claim)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Проблема:** Redis не предназначен для поиска по паттернам (медленно)
|
||||||
|
|
||||||
|
**Решение:** Использовать индекс в PostgreSQL:
|
||||||
|
```sql
|
||||||
|
CREATE INDEX idx_clpr_claims_status_channel
|
||||||
|
ON clpr_claims(status_code, channel)
|
||||||
|
WHERE status_code IN ('draft', 'in_work');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Рекомендуемая архитектура
|
||||||
|
|
||||||
|
### Для веб-формы:
|
||||||
|
|
||||||
|
1. **Основное хранилище:** PostgreSQL (`clpr_claims`)
|
||||||
|
- Полные данные
|
||||||
|
- История изменений
|
||||||
|
- Надежность
|
||||||
|
|
||||||
|
2. **Кеш:** Redis (`claim:CLM-...`)
|
||||||
|
- Быстрый доступ
|
||||||
|
- TTL 24 часа
|
||||||
|
- Lazy loading (загружаем при первом обращении)
|
||||||
|
|
||||||
|
3. **Алгоритм:**
|
||||||
|
```
|
||||||
|
Чтение:
|
||||||
|
1. Redis (если есть) → возврат
|
||||||
|
2. PostgreSQL → загрузка → сохранение в Redis → возврат
|
||||||
|
|
||||||
|
Запись:
|
||||||
|
1. PostgreSQL (основное)
|
||||||
|
2. Redis (обновление кеша или удаление)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **TTL стратегия:**
|
||||||
|
- Незавершенные заявки (`draft`, `in_work`): 7 дней
|
||||||
|
- Завершенные заявки (`submitted`): 1 час
|
||||||
|
- Продлеваем TTL при обращении
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Реализация в n8n
|
||||||
|
|
||||||
|
### После `claimsave`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Code Node: Save to Redis
|
||||||
|
const claim = $json.claim;
|
||||||
|
const channel = $json.channel || 'web_form';
|
||||||
|
|
||||||
|
if (channel === 'web_form') {
|
||||||
|
// Определяем TTL в зависимости от статуса
|
||||||
|
const status = claim.status_code || 'draft';
|
||||||
|
const ttl = (status === 'draft' || status === 'in_work')
|
||||||
|
? 604800 // 7 дней для незавершенных
|
||||||
|
: 3600; // 1 час для завершенных
|
||||||
|
|
||||||
|
return {
|
||||||
|
redis_key: `claim:${claim.claim_id_str}`,
|
||||||
|
redis_value: JSON.stringify({
|
||||||
|
claim_id: claim.claim_id_str,
|
||||||
|
contact_id: claim.payload?.contact_id,
|
||||||
|
phone: claim.payload?.phone,
|
||||||
|
status: status,
|
||||||
|
current_step: calculateStep(claim.payload),
|
||||||
|
payload: {
|
||||||
|
answers: claim.payload?.answers,
|
||||||
|
wizard_plan: claim.payload?.wizard_plan,
|
||||||
|
documents_meta: claim.payload?.documents_meta
|
||||||
|
},
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
}),
|
||||||
|
ttl: ttl
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redis Node: SET with TTL
|
||||||
|
// Key: {{ $json.redis_key }}
|
||||||
|
// Value: {{ $json.redis_value }}
|
||||||
|
// TTL: {{ $json.ttl }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### При чтении (личный кабинет):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Code Node: Get claim with cache
|
||||||
|
const claim_id = $json.claim_id;
|
||||||
|
|
||||||
|
// 1. Пробуем Redis
|
||||||
|
const cached = await redis.get(`claim:${claim_id}`);
|
||||||
|
if (cached) {
|
||||||
|
return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Если нет - из PostgreSQL
|
||||||
|
// (выполняется SQL запрос)
|
||||||
|
const claim = await postgres.get_claim(claim_id);
|
||||||
|
|
||||||
|
// 3. Сохраняем в Redis
|
||||||
|
if (claim) {
|
||||||
|
await redis.set(`claim:${claim_id}`, JSON.stringify(claim), 'EX', 86400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return claim;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
### Рекомендуемая архитектура:
|
||||||
|
|
||||||
|
1. **PostgreSQL** - основное хранилище (источник истины)
|
||||||
|
2. **Redis** - кеш для быстрого доступа (TTL 24 часа, продлеваем при обращении)
|
||||||
|
3. **Lazy loading** - загружаем в Redis при первом обращении
|
||||||
|
4. **Инвалидация** - обновляем или удаляем кеш при изменении данных
|
||||||
|
|
||||||
|
### Преимущества:
|
||||||
|
- ✅ Быстрый доступ (если есть в кеше)
|
||||||
|
- ✅ Надежность (данные в PostgreSQL)
|
||||||
|
- ✅ Автоматическая очистка (TTL)
|
||||||
|
- ✅ Гибкость (можно отключить кеш, если не нужен)
|
||||||
|
|
||||||
|
### Когда использовать:
|
||||||
|
- ✅ Личный кабинет (список незавершенных заявок)
|
||||||
|
- ✅ Возобновление заполнения формы
|
||||||
|
- ✅ Быстрая загрузка состояния формы
|
||||||
|
|
||||||
73
docs/PROMPT_UPDATE_GUIDE.md
Normal file
73
docs/PROMPT_UPDATE_GUIDE.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Инструкция по обновлению промпта в n8n
|
||||||
|
|
||||||
|
## Текущая ситуация
|
||||||
|
|
||||||
|
**Используется:** `optimized_wizard_prompt.txt` (включает RAG)
|
||||||
|
**Время генерации:** 23-35 секунд
|
||||||
|
|
||||||
|
**Новый промпт:** `wizard_prompt_simple.txt` (без RAG)
|
||||||
|
**Ожидаемое время:** 5-10 секунд (без RAG)
|
||||||
|
|
||||||
|
## Шаги для обновления
|
||||||
|
|
||||||
|
### 1. Открыть workflow в n8n
|
||||||
|
|
||||||
|
1. Зайти в n8n: https://n8n.clientright.pro
|
||||||
|
2. Найти workflow с ID `b4K4u851b4JFivyD` (или тот, который обрабатывает `ticket_form:description`)
|
||||||
|
3. Найти ноду **AI Agent** или **OpenAI** (которая генерирует визард)
|
||||||
|
|
||||||
|
### 2. Обновить промпт
|
||||||
|
|
||||||
|
**Старый промпт (с RAG):**
|
||||||
|
```
|
||||||
|
Ты — аналитик по делам защиты прав потребителей. Создай динамический чек-лист (5-7 вопросов) + список документов для претензии/иска.
|
||||||
|
|
||||||
|
ВХОД:
|
||||||
|
- USER_MESSAGE: "{{ $json.chatInput }}"
|
||||||
|
- RAG_ANSWER: "{{ $json.output }}"
|
||||||
|
- FORM_STEPS: {{ $json.questions_numbered_html }}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Новый промпт (без RAG):**
|
||||||
|
```
|
||||||
|
# Роль
|
||||||
|
|
||||||
|
Ты — юридический ассистент по защите прав потребителей. Ты помогаешь людям понять, какие необходимо собрать документы и сообщить дополнительные сведения, для решения их проблемы.
|
||||||
|
|
||||||
|
# Задача: Построение динамического визарда
|
||||||
|
|
||||||
|
Твоя задача — проанализировать описание проблемы пользователя и создать **динамический визард** — структурированный набор вопросов и списка документов, которые помогут собрать всю необходимую информацию для подготовки претензии или иска.
|
||||||
|
|
||||||
|
## Входные данные
|
||||||
|
|
||||||
|
Ты получаешь только:
|
||||||
|
- **USER_DESCRIPTION**: "{{ $json.chatInput }}"
|
||||||
|
|
||||||
|
[Далее весь текст из wizard_prompt_simple.txt]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Убрать RAG из workflow (опционально)
|
||||||
|
|
||||||
|
Если RAG не нужен, можно:
|
||||||
|
1. Удалить ноду RAG/поиска
|
||||||
|
2. Убрать `RAG_ANSWER` из промпта
|
||||||
|
3. Упростить входные данные до одного поля: `USER_DESCRIPTION`
|
||||||
|
|
||||||
|
### 4. Протестировать
|
||||||
|
|
||||||
|
1. Отправить тестовое описание через форму
|
||||||
|
2. Проверить время генерации (должно быть 5-10 сек вместо 23-35 сек)
|
||||||
|
3. Проверить качество визарда (вопросы и документы должны быть релевантными)
|
||||||
|
|
||||||
|
## Ожидаемый результат
|
||||||
|
|
||||||
|
- ⚡ **Время генерации:** 5-10 секунд (вместо 23-35)
|
||||||
|
- 📝 **Качество:** такое же или лучше (более структурированный промпт)
|
||||||
|
- 💰 **Стоимость:** ниже (нет RAG запросов)
|
||||||
|
|
||||||
|
## Откат (если что-то пошло не так)
|
||||||
|
|
||||||
|
1. Вернуть старый промпт из `optimized_wizard_prompt.txt`
|
||||||
|
2. Восстановить RAG ноду (если удаляли)
|
||||||
|
3. Проверить, что всё работает как раньше
|
||||||
|
|
||||||
191
docs/REDIS_CLAIM_STORAGE_ANALYSIS.md
Normal file
191
docs/REDIS_CLAIM_STORAGE_ANALYSIS.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# Анализ: Нужно ли хранить данные заявки в Redis?
|
||||||
|
|
||||||
|
## Текущая ситуация
|
||||||
|
|
||||||
|
### Что сейчас в Redis:
|
||||||
|
|
||||||
|
**Ключ:** `claim:CLM-2025-11-18-GEQ3KL`
|
||||||
|
|
||||||
|
**Значение:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"claim_id": "CLM-2025-11-18-GEQ3KL",
|
||||||
|
"contact_id": "398523",
|
||||||
|
"phone": "72352352352",
|
||||||
|
"is_new_contact": true,
|
||||||
|
"status": "draft",
|
||||||
|
"current_step": 2,
|
||||||
|
"created_at": "2025-11-18T20:43:47.033Z",
|
||||||
|
"updated_at": "2025-11-18T20:44:59.217Z",
|
||||||
|
"voucher": null,
|
||||||
|
"event_type": null,
|
||||||
|
"documents": {},
|
||||||
|
"email": null,
|
||||||
|
"bank_name": null,
|
||||||
|
"project_id": "398524",
|
||||||
|
"is_new_project": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**TTL:** ~6.5 дней (563566 секунд)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Для чего использовался Redis (Telegram бот)
|
||||||
|
|
||||||
|
### Исторически:
|
||||||
|
1. **Быстрый доступ к сессии** - Telegram бот не имеет постоянного состояния
|
||||||
|
2. **Хранение промежуточных данных** - пока пользователь заполняет форму
|
||||||
|
3. **TTL 7 дней** - автоматическая очистка старых сессий
|
||||||
|
4. **Легковесное хранилище** - не нужна полная БД для временных данных
|
||||||
|
|
||||||
|
### Проблемы:
|
||||||
|
- ❌ Дублирование данных (есть в PostgreSQL)
|
||||||
|
- ❌ Нужно синхронизировать Redis и PostgreSQL
|
||||||
|
- ❌ Риск рассинхронизации данных
|
||||||
|
- ❌ Дополнительная сложность
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Текущая архитектура (веб-форма)
|
||||||
|
|
||||||
|
### PostgreSQL (основное хранилище):
|
||||||
|
- ✅ `clpr_claims` - полные данные заявки в `payload` (JSONB)
|
||||||
|
- ✅ `clpr_claim_documents` - документы
|
||||||
|
- ✅ Постоянное хранилище
|
||||||
|
- ✅ Транзакции и целостность данных
|
||||||
|
- ✅ История изменений (updated_at)
|
||||||
|
|
||||||
|
### Redis (только Pub/Sub):
|
||||||
|
- ✅ `ocr_events:{claim_id}` - события обработки файлов (SSE)
|
||||||
|
- ✅ Временные события, не хранятся постоянно
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Нужно ли хранить в Redis для веб-формы?
|
||||||
|
|
||||||
|
### ❌ НЕТ, не нужно!
|
||||||
|
|
||||||
|
**Причины:**
|
||||||
|
|
||||||
|
1. **Данные уже в PostgreSQL**
|
||||||
|
- Все данные заявки хранятся в `clpr_claims.payload`
|
||||||
|
- Полная информация доступна из БД
|
||||||
|
- Нет необходимости дублировать
|
||||||
|
|
||||||
|
2. **Веб-форма != Telegram бот**
|
||||||
|
- Telegram бот: нет постоянного состояния, нужен быстрый доступ к сессии
|
||||||
|
- Веб-форма: состояние хранится в React (useState), данные в PostgreSQL
|
||||||
|
- Не нужен промежуточный кеш
|
||||||
|
|
||||||
|
3. **Риск рассинхронизации**
|
||||||
|
- Если данные в Redis и PostgreSQL расходятся - проблемы
|
||||||
|
- Сложнее поддерживать консистентность
|
||||||
|
- Дополнительная точка отказа
|
||||||
|
|
||||||
|
4. **Усложнение архитектуры**
|
||||||
|
- Нужно обновлять и Redis, и PostgreSQL
|
||||||
|
- Больше кода для поддержки
|
||||||
|
- Больше мест, где может что-то сломаться
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что делать с существующими данными в Redis?
|
||||||
|
|
||||||
|
### Вариант 1: Оставить как есть (для совместимости)
|
||||||
|
- ✅ Не ломает существующий Telegram бот
|
||||||
|
- ✅ Можно использовать для быстрого доступа к базовым данным
|
||||||
|
- ❌ Дублирование данных
|
||||||
|
- ❌ Нужно синхронизировать
|
||||||
|
|
||||||
|
### Вариант 2: Убрать для веб-формы, оставить для Telegram
|
||||||
|
- ✅ Чистая архитектура для веб-формы
|
||||||
|
- ✅ Telegram бот продолжает работать
|
||||||
|
- ✅ Нет дублирования для веб-формы
|
||||||
|
- ⚠️ Нужно различать источник (channel: 'web_form' vs 'telegram')
|
||||||
|
|
||||||
|
### Вариант 3: Полностью убрать (миграция на PostgreSQL)
|
||||||
|
- ✅ Единый источник истины (PostgreSQL)
|
||||||
|
- ✅ Проще архитектура
|
||||||
|
- ❌ Нужно мигрировать Telegram бот
|
||||||
|
- ❌ Может сломать существующую логику
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Рекомендация
|
||||||
|
|
||||||
|
### Для веб-формы (`channel: 'web_form'`):
|
||||||
|
|
||||||
|
**НЕ сохранять в Redis**, потому что:
|
||||||
|
|
||||||
|
1. ✅ Данные уже в PostgreSQL (`clpr_claims`)
|
||||||
|
2. ✅ Состояние формы в React (`useState`)
|
||||||
|
3. ✅ Нет необходимости в промежуточном кеше
|
||||||
|
4. ✅ Меньше сложности, меньше багов
|
||||||
|
|
||||||
|
### Для Telegram бота (`channel: 'telegram'`):
|
||||||
|
|
||||||
|
**Оставить Redis** (если используется), потому что:
|
||||||
|
|
||||||
|
1. ✅ Telegram бот может нуждаться в быстром доступе к сессии
|
||||||
|
2. ✅ Нет постоянного состояния в боте
|
||||||
|
3. ✅ TTL автоматически очищает старые сессии
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
**Для веб-формы (`ticket_form`):**
|
||||||
|
- ❌ **НЕ нужно** сохранять в Redis `claim:CLM-...`
|
||||||
|
- ✅ Все данные в PostgreSQL (`clpr_claims`)
|
||||||
|
- ✅ Redis используется только для Pub/Sub (`ocr_events:{claim_id}`)
|
||||||
|
|
||||||
|
**Для Telegram бота:**
|
||||||
|
- ✅ Можно оставить Redis для совместимости
|
||||||
|
- ⚠️ Но лучше тоже мигрировать на PostgreSQL для единообразия
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что делать в n8n workflow?
|
||||||
|
|
||||||
|
### В ноде `claimsave` и `claimsave_final`:
|
||||||
|
|
||||||
|
**НЕ добавлять сохранение в Redis**, если:
|
||||||
|
- `channel = 'web_form'` (веб-форма)
|
||||||
|
- Данные уже сохранены в PostgreSQL
|
||||||
|
|
||||||
|
**Можно добавить сохранение в Redis**, если:
|
||||||
|
- `channel = 'telegram'` (Telegram бот)
|
||||||
|
- Нужна обратная совместимость
|
||||||
|
|
||||||
|
### Пример проверки в n8n:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// После SQL запроса (claimsave)
|
||||||
|
const channel = $json.channel || 'web_form';
|
||||||
|
|
||||||
|
if (channel === 'telegram') {
|
||||||
|
// Сохраняем в Redis для Telegram бота
|
||||||
|
return {
|
||||||
|
redis_key: `claim:${$json.claim_id}`,
|
||||||
|
redis_value: JSON.stringify({
|
||||||
|
claim_id: $json.claim_id,
|
||||||
|
contact_id: $json.contact_id,
|
||||||
|
// ... остальные поля
|
||||||
|
}),
|
||||||
|
ttl: 604800 // 7 дней
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Для веб-формы - не сохраняем в Redis
|
||||||
|
return $json;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Вывод
|
||||||
|
|
||||||
|
**Для веб-формы НЕ нужно сохранять в Redis `claim:CLM-...`**
|
||||||
|
|
||||||
|
Все данные уже в PostgreSQL, и этого достаточно. Redis используется только для Pub/Sub событий (`ocr_events:{claim_id}`).
|
||||||
|
|
||||||
198
docs/REDIS_VS_POSTGRESQL_SPEED.md
Normal file
198
docs/REDIS_VS_POSTGRESQL_SPEED.md
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
# Redis vs PostgreSQL: Когда что использовать?
|
||||||
|
|
||||||
|
## Скорость доступа
|
||||||
|
|
||||||
|
### Redis:
|
||||||
|
- ⚡ **0.1-1 мс** (данные в памяти)
|
||||||
|
- Мгновенный доступ
|
||||||
|
- Идеально для частых чтений
|
||||||
|
|
||||||
|
### PostgreSQL:
|
||||||
|
- 🐢 **1-10 мс** (с индексами)
|
||||||
|
- Зависит от нагрузки и индексов
|
||||||
|
- Но всё равно очень быстро
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Когда Redis имеет смысл
|
||||||
|
|
||||||
|
### ✅ Используй Redis, если:
|
||||||
|
|
||||||
|
1. **Очень частые чтения** (каждый запрос, каждый клик)
|
||||||
|
- Например: счетчики, rate limiting, сессии
|
||||||
|
|
||||||
|
2. **Временные данные** (TTL, автоочистка)
|
||||||
|
- Например: SMS коды, временные токены
|
||||||
|
|
||||||
|
3. **Кеширование результатов запросов**
|
||||||
|
- Например: результаты AI классификации, шаблоны визардов
|
||||||
|
|
||||||
|
4. **Pub/Sub события** (реал-тайм)
|
||||||
|
- Например: `ocr_events:{claim_id}` для SSE
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Когда PostgreSQL достаточно
|
||||||
|
|
||||||
|
### ✅ Используй только PostgreSQL, если:
|
||||||
|
|
||||||
|
1. **Данные читаются не так часто**
|
||||||
|
- Загрузка страницы, переход между шагами
|
||||||
|
- Пользователь не заметит разницу 1-10 мс
|
||||||
|
|
||||||
|
2. **Важна консистентность**
|
||||||
|
- Нужна гарантия актуальности данных
|
||||||
|
- Нет риска рассинхронизации
|
||||||
|
|
||||||
|
3. **Данные уже в PostgreSQL**
|
||||||
|
- Не нужно дублировать
|
||||||
|
- Проще архитектура
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Для веб-формы: Анализ использования
|
||||||
|
|
||||||
|
### Когда читаются данные заявки:
|
||||||
|
|
||||||
|
1. **При загрузке страницы** (1 раз)
|
||||||
|
- Пользователь открывает форму
|
||||||
|
- Можно загрузить из PostgreSQL (10 мс) - не критично
|
||||||
|
|
||||||
|
2. **При переходах между шагами** (редко)
|
||||||
|
- Пользователь нажимает "Далее"
|
||||||
|
- Можно загрузить из PostgreSQL (10 мс) - не критично
|
||||||
|
|
||||||
|
3. **При обновлении данных** (редко)
|
||||||
|
- Пользователь заполняет форму
|
||||||
|
- Сохраняется в PostgreSQL
|
||||||
|
|
||||||
|
### Вывод:
|
||||||
|
- ❌ **НЕ критично по скорости** - пользователь не заметит разницу
|
||||||
|
- ✅ **Важнее консистентность** - данные всегда актуальные
|
||||||
|
- ✅ **Проще архитектура** - один источник истины
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Компромиссное решение
|
||||||
|
|
||||||
|
### Вариант: Кеширование в Redis с инвалидацией
|
||||||
|
|
||||||
|
```python
|
||||||
|
# При чтении данных заявки
|
||||||
|
async def get_claim(claim_id: str):
|
||||||
|
# 1. Пробуем Redis (быстро)
|
||||||
|
cached = await redis.get(f"claim:{claim_id}")
|
||||||
|
if cached:
|
||||||
|
return json.loads(cached)
|
||||||
|
|
||||||
|
# 2. Если нет в кеше - из PostgreSQL
|
||||||
|
claim = await db.get_claim(claim_id)
|
||||||
|
|
||||||
|
# 3. Сохраняем в кеш на 1 час
|
||||||
|
await redis.set(f"claim:{claim_id}", json.dumps(claim), ttl=3600)
|
||||||
|
|
||||||
|
return claim
|
||||||
|
|
||||||
|
# При обновлении данных
|
||||||
|
async def update_claim(claim_id: str, data: dict):
|
||||||
|
# 1. Обновляем PostgreSQL
|
||||||
|
await db.update_claim(claim_id, data)
|
||||||
|
|
||||||
|
# 2. Инвалидируем кеш (удаляем из Redis)
|
||||||
|
await redis.delete(f"claim:{claim_id}")
|
||||||
|
|
||||||
|
# Или обновляем кеш сразу
|
||||||
|
await redis.set(f"claim:{claim_id}", json.dumps(data), ttl=3600)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Плюсы:
|
||||||
|
- ✅ Быстрый доступ (если есть в кеше)
|
||||||
|
- ✅ Актуальные данные (инвалидация при обновлении)
|
||||||
|
- ✅ Fallback на PostgreSQL (если кеш пуст)
|
||||||
|
|
||||||
|
### Минусы:
|
||||||
|
- ❌ Дополнительная сложность
|
||||||
|
- ❌ Нужно инвалидировать кеш при каждом обновлении
|
||||||
|
- ❌ Риск устаревших данных (если забыли инвалидировать)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Рекомендация для веб-формы
|
||||||
|
|
||||||
|
### Вариант 1: Только PostgreSQL (рекомендую)
|
||||||
|
|
||||||
|
**Когда использовать:**
|
||||||
|
- Данные читаются не так часто (загрузка страницы, переходы)
|
||||||
|
- Важна консистентность
|
||||||
|
- Простота архитектуры важнее скорости
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- ✅ Просто (один источник данных)
|
||||||
|
- ✅ Всегда актуальные данные
|
||||||
|
- ✅ Нет рассинхронизации
|
||||||
|
- ✅ PostgreSQL с индексами всё равно быстро (1-10 мс)
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- ❌ Чуть медленнее, чем Redis (но не критично)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Вариант 2: PostgreSQL + Redis кеш (если нужна скорость)
|
||||||
|
|
||||||
|
**Когда использовать:**
|
||||||
|
- Очень частые чтения (каждый запрос)
|
||||||
|
- Критична скорость (но для веб-формы это не так)
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- ✅ Быстрый доступ (0.1-1 мс)
|
||||||
|
- ✅ Меньше нагрузки на PostgreSQL
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- ❌ Сложнее (нужна инвалидация кеша)
|
||||||
|
- ❌ Риск устаревших данных
|
||||||
|
- ❌ Больше кода для поддержки
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
### Для веб-формы:
|
||||||
|
|
||||||
|
**Рекомендую: Только PostgreSQL**
|
||||||
|
|
||||||
|
**Почему:**
|
||||||
|
1. ⚡ PostgreSQL с индексами быстро (1-10 мс) - пользователь не заметит
|
||||||
|
2. ✅ Всегда актуальные данные (нет рассинхронизации)
|
||||||
|
3. ✅ Проще архитектура (один источник истины)
|
||||||
|
4. ✅ Данные читаются не так часто (не каждый запрос)
|
||||||
|
|
||||||
|
**Redis используй только для:**
|
||||||
|
- ✅ Pub/Sub (`ocr_events:{claim_id}`) - события в реальном времени
|
||||||
|
- ✅ Кеширование AI ответов (классификация, визарды) - если нужно
|
||||||
|
- ✅ SMS коды, временные токены - с TTL
|
||||||
|
|
||||||
|
**НЕ используй Redis для:**
|
||||||
|
- ❌ Основных данных заявки (есть в PostgreSQL)
|
||||||
|
- ❌ Документов (есть в PostgreSQL)
|
||||||
|
- ❌ Ответов визарда (есть в PostgreSQL)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Если всё-таки нужен Redis кеш
|
||||||
|
|
||||||
|
Можно добавить опциональное кеширование:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# В n8n workflow после claimsave
|
||||||
|
if (channel === 'web_form' && enable_cache === true) {
|
||||||
|
// Опционально: кешируем в Redis на 1 час
|
||||||
|
await redis.set(
|
||||||
|
`claim:${claim_id}`,
|
||||||
|
JSON.stringify(claim_data),
|
||||||
|
ttl=3600
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Но это опционально и не обязательно для веб-формы.
|
||||||
|
|
||||||
72
docs/SESSION_LOG_2025-11-19.md
Normal file
72
docs/SESSION_LOG_2025-11-19.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Лог сессии разработки - 19 ноября 2025
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
После верификации телефона не отображается список черновиков, хотя в базе данных есть заявки с `unified_id = 'usr_90599ff2-ac79-4236-b950-0df85395096c'`.
|
||||||
|
|
||||||
|
## Что было сделано
|
||||||
|
|
||||||
|
### 1. Добавлено логирование в frontend
|
||||||
|
- В `ClaimForm.tsx` добавлены логи для отслеживания:
|
||||||
|
- Вызов `onNext` с `unified_id`
|
||||||
|
- Проверка условий для показа черновиков
|
||||||
|
- Запрос к API `/api/v1/claims/drafts/list`
|
||||||
|
- Ответ от API
|
||||||
|
|
||||||
|
### 2. Добавлено логирование в backend
|
||||||
|
- В `claims.py` добавлены логи для отладки запроса черновиков:
|
||||||
|
- Тестовый COUNT запрос для проверки наличия данных в БД
|
||||||
|
- Количество найденных строк
|
||||||
|
- Детали первой строки
|
||||||
|
|
||||||
|
### 3. Проверка данных в БД
|
||||||
|
- Проверено напрямую через psql: есть 17 заявок для `unified_id = 'usr_90599ff2-ac79-4236-b950-0df85395096c'`
|
||||||
|
- Из них 3 со статусом `draft`
|
||||||
|
- Все заявки с каналом `telegram` (не `web_form`)
|
||||||
|
|
||||||
|
### 4. Проблема
|
||||||
|
- API `/api/v1/claims/drafts/list?unified_id=...` возвращает `{"success":true,"count":0,"drafts":[]}`
|
||||||
|
- Логи в backend не появляются (logger.info не выводится в консоль)
|
||||||
|
- SQL запрос напрямую в psql работает и возвращает данные
|
||||||
|
|
||||||
|
## Текущее состояние
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- `unified_id` приходит от n8n и отображается в консоли браузера
|
||||||
|
- `unified_id` передается в `onNext` callback
|
||||||
|
- `checkDrafts` вызывается с правильным `unified_id`
|
||||||
|
- Но API возвращает 0 черновиков
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Endpoint `/api/v1/claims/drafts/list` существует
|
||||||
|
- Запрос к БД должен работать (проверено через psql)
|
||||||
|
- Но логи не появляются, что странно
|
||||||
|
|
||||||
|
## Что нужно проверить дальше
|
||||||
|
|
||||||
|
1. **Почему логи не появляются?**
|
||||||
|
- Проверить настройки логирования в FastAPI
|
||||||
|
- Возможно, нужно использовать `print()` вместо `logger.info()`
|
||||||
|
|
||||||
|
2. **Почему запрос возвращает 0 результатов?**
|
||||||
|
- Проверить, что `asyncpg` правильно выполняет запрос
|
||||||
|
- Возможно, проблема с параметрами запроса
|
||||||
|
- Проверить, что `unified_id` правильно передается в SQL
|
||||||
|
|
||||||
|
3. **Проверить в браузере:**
|
||||||
|
- Открыть консоль разработчика
|
||||||
|
- Проверить логи `🔥 onNext вызван с unified_id:`
|
||||||
|
- Проверить логи `🔍 Запрос черновиков:`
|
||||||
|
- Проверить ответ API `🔍 Ответ API черновиков:`
|
||||||
|
|
||||||
|
## Файлы изменены
|
||||||
|
|
||||||
|
1. `frontend/src/pages/ClaimForm.tsx` - добавлено логирование
|
||||||
|
2. `backend/app/api/claims.py` - добавлено логирование и тестовые запросы
|
||||||
|
|
||||||
|
## Следующие шаги
|
||||||
|
|
||||||
|
1. Проверить логи в браузере после перезагрузки
|
||||||
|
2. Проверить, что API действительно вызывается
|
||||||
|
3. Если API вызывается, но возвращает 0 - проверить SQL запрос в backend
|
||||||
|
4. Если SQL работает, но asyncpg не возвращает данные - проверить формат параметров
|
||||||
|
|
||||||
114
docs/SQL_FIND_OR_CREATE_USER_WEB_FORM.sql
Normal file
114
docs/SQL_FIND_OR_CREATE_USER_WEB_FORM.sql
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- SQL запрос для n8n: Поиск/создание пользователя для web_form
|
||||||
|
-- ============================================================================
|
||||||
|
-- Назначение: Найти существующего пользователя по телефону или создать нового
|
||||||
|
-- в PostgreSQL для канала web_form
|
||||||
|
--
|
||||||
|
-- Параметры:
|
||||||
|
-- $1 = phone (номер телефона в любом формате: '79991234567', '+79991234567', '89991234567', '9991234567')
|
||||||
|
--
|
||||||
|
-- Возвращает:
|
||||||
|
-- unified_id - уникальный идентификатор пользователя (usr_...)
|
||||||
|
-- user_id - внутренний ID пользователя в clpr_users
|
||||||
|
--
|
||||||
|
-- Нормализация телефона:
|
||||||
|
-- - Убирает все нецифровые символы
|
||||||
|
-- - Если начинается с 8, заменяет на 7
|
||||||
|
-- - Если 10 цифр, добавляет 7 в начало
|
||||||
|
-- - Результат: 7XXXXXXXXXX (11 цифр)
|
||||||
|
--
|
||||||
|
-- Использование в n8n:
|
||||||
|
-- 1. PostgreSQL node
|
||||||
|
-- 2. Query Type: Execute Query
|
||||||
|
-- 3. Parameters: $1 = {{$json.phone}}
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
WITH normalized_phone AS (
|
||||||
|
-- Нормализуем телефон: убираем все нецифры, приводим к формату 7XXXXXXXXXX
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN LENGTH(REGEXP_REPLACE($1, '[^0-9]', '', 'g')) = 11
|
||||||
|
AND SUBSTRING(REGEXP_REPLACE($1, '[^0-9]', '', 'g') FROM 1 FOR 1) = '8'
|
||||||
|
THEN '7' || SUBSTRING(REGEXP_REPLACE($1, '[^0-9]', '', 'g') FROM 2)
|
||||||
|
WHEN LENGTH(REGEXP_REPLACE($1, '[^0-9]', '', 'g')) = 10
|
||||||
|
THEN '7' || REGEXP_REPLACE($1, '[^0-9]', '', 'g')
|
||||||
|
WHEN LENGTH(REGEXP_REPLACE($1, '[^0-9]', '', 'g')) = 11
|
||||||
|
AND SUBSTRING(REGEXP_REPLACE($1, '[^0-9]', '', 'g') FROM 1 FOR 1) = '7'
|
||||||
|
THEN REGEXP_REPLACE($1, '[^0-9]', '', 'g')
|
||||||
|
ELSE REGEXP_REPLACE($1, '[^0-9]', '', 'g')
|
||||||
|
END AS phone_normalized
|
||||||
|
),
|
||||||
|
|
||||||
|
existing AS (
|
||||||
|
-- Шаг 1: Ищем существующего пользователя по нормализованному телефону в clpr_users
|
||||||
|
-- НЕ ищем в clpr_user_accounts для web_form, чтобы не трогать существующие записи других каналов
|
||||||
|
SELECT u.id AS user_id, u.unified_id
|
||||||
|
FROM normalized_phone np
|
||||||
|
JOIN clpr_users u ON u.phone = np.phone_normalized
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
|
||||||
|
create_user AS (
|
||||||
|
-- Шаг 2: Создаем нового пользователя, если не найден (с нормализованным телефоном)
|
||||||
|
INSERT INTO clpr_users (unified_id, phone, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
'usr_' || gen_random_uuid()::text AS unified_id,
|
||||||
|
np.phone_normalized AS phone,
|
||||||
|
now() AS created_at,
|
||||||
|
now() AS updated_at
|
||||||
|
FROM normalized_phone np
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM existing)
|
||||||
|
RETURNING id AS user_id, unified_id
|
||||||
|
),
|
||||||
|
|
||||||
|
final_user AS (
|
||||||
|
-- Шаг 3: Объединяем существующего и созданного пользователя
|
||||||
|
SELECT * FROM existing
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM create_user
|
||||||
|
),
|
||||||
|
|
||||||
|
update_unified AS (
|
||||||
|
-- Шаг 4: Обновляем unified_id, если он NULL (для старых записей)
|
||||||
|
UPDATE clpr_users
|
||||||
|
SET unified_id = COALESCE(
|
||||||
|
unified_id,
|
||||||
|
'usr_' || gen_random_uuid()::text
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = (SELECT user_id FROM final_user LIMIT 1)
|
||||||
|
AND unified_id IS NULL
|
||||||
|
RETURNING id AS user_id, unified_id
|
||||||
|
),
|
||||||
|
|
||||||
|
final_unified_id AS (
|
||||||
|
-- Шаг 5: Получаем финальный unified_id (из update или из final_user)
|
||||||
|
SELECT unified_id FROM update_unified
|
||||||
|
UNION ALL
|
||||||
|
SELECT unified_id FROM final_user
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM update_unified)
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
|
||||||
|
create_account AS (
|
||||||
|
-- Шаг 6: Создаем запись в clpr_user_accounts для web_form только если её еще нет
|
||||||
|
-- НЕ обновляем существующие записи других каналов (telegram и т.д.)
|
||||||
|
INSERT INTO clpr_user_accounts(user_id, channel, channel_user_id)
|
||||||
|
SELECT
|
||||||
|
(SELECT user_id FROM final_user LIMIT 1) AS user_id,
|
||||||
|
'web_form' AS channel,
|
||||||
|
np.phone_normalized AS channel_user_id -- нормализованный телефон
|
||||||
|
FROM normalized_phone np
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM clpr_user_accounts
|
||||||
|
WHERE channel = 'web_form'
|
||||||
|
AND channel_user_id = np.phone_normalized
|
||||||
|
)
|
||||||
|
ON CONFLICT (channel, channel_user_id) DO NOTHING
|
||||||
|
RETURNING user_id, channel, channel_user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Шаг 7: Возвращаем unified_id и user_id
|
||||||
|
SELECT
|
||||||
|
(SELECT unified_id FROM final_unified_id LIMIT 1) AS unified_id,
|
||||||
|
(SELECT user_id FROM final_user LIMIT 1) AS user_id;
|
||||||
131
docs/SQL_FIND_OR_CREATE_USER_WEB_FORM_N8N.md
Normal file
131
docs/SQL_FIND_OR_CREATE_USER_WEB_FORM_N8N.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# SQL запрос для n8n: Поиск/создание пользователя web_form
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
Поиск существующего пользователя по телефону или создание нового пользователя в PostgreSQL для канала `web_form`.
|
||||||
|
|
||||||
|
## Параметры
|
||||||
|
- `$1` (или `{{$json.phone}}` в n8n) - номер телефона (например, `79991234567`)
|
||||||
|
|
||||||
|
## Возвращает
|
||||||
|
- `unified_id` - уникальный идентификатор пользователя (например, `usr_203595f0-b70a-41d3-955f-80b4b2571469`)
|
||||||
|
- `user_id` - внутренний ID пользователя в таблице `clpr_users`
|
||||||
|
|
||||||
|
## Логика работы
|
||||||
|
|
||||||
|
1. **Поиск существующего пользователя** (`existing`):
|
||||||
|
- Ищет в `clpr_user_accounts` запись с `channel='web_form'` и `channel_user_id=phone`
|
||||||
|
- Получает `user_id` и `unified_id` из связанной таблицы `clpr_users`
|
||||||
|
|
||||||
|
2. **Создание нового пользователя** (`create_user`):
|
||||||
|
- Если пользователь не найден, создает новую запись в `clpr_users`
|
||||||
|
- Генерирует `unified_id` в формате `usr_{UUID}`
|
||||||
|
- Сохраняет телефон
|
||||||
|
|
||||||
|
3. **Обновление unified_id** (`update_unified`):
|
||||||
|
- Если у пользователя `unified_id` = NULL (старые записи), обновляет его
|
||||||
|
|
||||||
|
4. **Создание/обновление аккаунта** (`create_account`):
|
||||||
|
- Создает запись в `clpr_user_accounts` с `channel='web_form'` и `channel_user_id=phone`
|
||||||
|
- При конфликте (уже существует) обновляет `user_id`
|
||||||
|
|
||||||
|
5. **Возврат результата**:
|
||||||
|
- Возвращает `unified_id` и `user_id`
|
||||||
|
|
||||||
|
## Использование в n8n
|
||||||
|
|
||||||
|
### Вариант 1: PostgreSQL node с параметрами
|
||||||
|
```sql
|
||||||
|
WITH existing AS (
|
||||||
|
SELECT u.id AS user_id, u.unified_id
|
||||||
|
FROM clpr_user_accounts ua
|
||||||
|
JOIN clpr_users u ON u.id = ua.user_id
|
||||||
|
WHERE ua.channel = 'web_form'
|
||||||
|
AND ua.channel_user_id = $1
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
create_user AS (
|
||||||
|
INSERT INTO clpr_users (unified_id, phone, created_at, updated_at)
|
||||||
|
SELECT
|
||||||
|
'usr_' || gen_random_uuid()::text,
|
||||||
|
$1,
|
||||||
|
now(),
|
||||||
|
now()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM existing)
|
||||||
|
RETURNING id AS user_id, unified_id
|
||||||
|
),
|
||||||
|
final_user AS (
|
||||||
|
SELECT * FROM existing
|
||||||
|
UNION ALL
|
||||||
|
SELECT * FROM create_user
|
||||||
|
),
|
||||||
|
update_unified AS (
|
||||||
|
UPDATE clpr_users
|
||||||
|
SET unified_id = COALESCE(
|
||||||
|
unified_id,
|
||||||
|
'usr_' || gen_random_uuid()::text
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = (SELECT user_id FROM final_user LIMIT 1)
|
||||||
|
AND unified_id IS NULL
|
||||||
|
RETURNING id AS user_id, unified_id
|
||||||
|
),
|
||||||
|
final_unified_id AS (
|
||||||
|
SELECT unified_id FROM update_unified
|
||||||
|
UNION ALL
|
||||||
|
SELECT unified_id FROM final_user
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM update_unified)
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
create_account AS (
|
||||||
|
INSERT INTO clpr_user_accounts(user_id, channel, channel_user_id)
|
||||||
|
SELECT
|
||||||
|
(SELECT user_id FROM final_user LIMIT 1),
|
||||||
|
'web_form',
|
||||||
|
$1
|
||||||
|
ON CONFLICT (channel, channel_user_id) DO UPDATE
|
||||||
|
SET user_id = EXCLUDED.user_id
|
||||||
|
RETURNING user_id, channel, channel_user_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
(SELECT unified_id FROM final_unified_id LIMIT 1) AS unified_id,
|
||||||
|
(SELECT user_id FROM final_user LIMIT 1) AS user_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Параметры в n8n:**
|
||||||
|
- `$1` = `{{$json.phone}}` (номер телефона из предыдущего шага)
|
||||||
|
|
||||||
|
### Вариант 2: С подстановкой через n8n expressions
|
||||||
|
```sql
|
||||||
|
WITH existing AS (
|
||||||
|
SELECT u.id AS user_id, u.unified_id
|
||||||
|
FROM clpr_user_accounts ua
|
||||||
|
JOIN clpr_users u ON u.id = ua.user_id
|
||||||
|
WHERE ua.channel = 'web_form'
|
||||||
|
AND ua.channel_user_id = '{{$json.phone}}'
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
-- ... остальной запрос аналогично, но везде $1 заменяется на '{{$json.phone}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Пример ответа
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"unified_id": "usr_203595f0-b70a-41d3-955f-80b4b2571469",
|
||||||
|
"user_id": 123
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Важные замечания
|
||||||
|
|
||||||
|
1. **Формат телефона**: Должен быть в формате `79991234567` (11 цифр, начинается с 7)
|
||||||
|
2. **Уникальность**: `(channel, channel_user_id)` в `clpr_user_accounts` должны быть уникальными
|
||||||
|
3. **unified_id**: Генерируется автоматически в формате `usr_{UUID}`
|
||||||
|
4. **Идемпотентность**: Запрос можно выполнять многократно - он вернет существующего пользователя или создаст нового
|
||||||
|
|
||||||
|
## Интеграция с workflow
|
||||||
|
|
||||||
|
После выполнения этого запроса:
|
||||||
|
1. Сохранить `unified_id` в Redis (например, в ключ `claim:{claim_id}`)
|
||||||
|
2. Вернуть `unified_id` в ответе frontend (в `result.unified_id`)
|
||||||
|
3. При создании/обновлении черновика заполнять `clpr_claims.unified_id = unified_id`
|
||||||
|
|
||||||
72
docs/SQL_GET_ALL_CLAIMS_BY_UNIFIED_ID.sql
Normal file
72
docs/SQL_GET_ALL_CLAIMS_BY_UNIFIED_ID.sql
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- SQL запрос: Получить все заявки пользователя по unified_id
|
||||||
|
-- ============================================================================
|
||||||
|
-- Назначение: Получить список всех заявок (все статусы) для пользователя
|
||||||
|
--
|
||||||
|
-- Параметры:
|
||||||
|
-- $1 = unified_id (например: 'usr_90599ff2-ac79-4236-b950-0df85395096c')
|
||||||
|
--
|
||||||
|
-- Возвращает:
|
||||||
|
-- - Все заявки с разными статусами (draft, active, in_work, etc.)
|
||||||
|
-- - Все каналы (web_form, telegram)
|
||||||
|
-- - Колонка status_code для фильтрации на фронтенде
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.payload->>'claim_id' as claim_id,
|
||||||
|
c.session_token,
|
||||||
|
c.status_code,
|
||||||
|
c.channel,
|
||||||
|
c.payload,
|
||||||
|
c.created_at,
|
||||||
|
c.updated_at
|
||||||
|
FROM clpr_claims c
|
||||||
|
WHERE c.unified_id = $1
|
||||||
|
ORDER BY c.updated_at DESC
|
||||||
|
LIMIT 20;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Fallback: Поиск по телефону (через clpr_user_accounts и clpr_users)
|
||||||
|
-- ============================================================================
|
||||||
|
-- Если unified_id неизвестен, можно найти через телефон:
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.payload->>'claim_id' as claim_id,
|
||||||
|
c.session_token,
|
||||||
|
c.status_code,
|
||||||
|
c.channel,
|
||||||
|
c.payload,
|
||||||
|
c.created_at,
|
||||||
|
c.updated_at
|
||||||
|
FROM clpr_claims c
|
||||||
|
WHERE c.unified_id = (
|
||||||
|
SELECT u.unified_id
|
||||||
|
FROM clpr_user_accounts ua
|
||||||
|
JOIN clpr_users u ON u.id = ua.user_id
|
||||||
|
WHERE ua.channel = 'web_form'
|
||||||
|
AND ua.channel_user_id = $1 -- phone (нормализованный)
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
ORDER BY c.updated_at DESC
|
||||||
|
LIMIT 20;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Fallback: Поиск по session_token
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.payload->>'claim_id' as claim_id,
|
||||||
|
c.session_token,
|
||||||
|
c.status_code,
|
||||||
|
c.channel,
|
||||||
|
c.payload,
|
||||||
|
c.created_at,
|
||||||
|
c.updated_at
|
||||||
|
FROM clpr_claims c
|
||||||
|
WHERE c.session_token = $1 -- session_id
|
||||||
|
ORDER BY c.updated_at DESC
|
||||||
|
LIMIT 20;
|
||||||
|
|
||||||
261
docs/WIZARD_API_ALTERNATIVES.md
Normal file
261
docs/WIZARD_API_ALTERNATIVES.md
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
# Готовые API и решения для построения визардов
|
||||||
|
|
||||||
|
**Дата:** 2025-01-XX
|
||||||
|
**Цель:** Найти готовые API/сервисы для генерации структуры визарда
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Результаты поиска
|
||||||
|
|
||||||
|
### ❌ Готовых API для генерации структуры визарда НЕТ
|
||||||
|
|
||||||
|
**Что найдено:**
|
||||||
|
- Библиотеки для **рендеринга** визардов на фронтенде (React, Vue, JS)
|
||||||
|
- Сервисы для **создания форм** программно (Form.io, Typeform)
|
||||||
|
- Но **НЕТ** API, который принимает описание проблемы и возвращает структуру визарда
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Найденные решения (для рендеринга)
|
||||||
|
|
||||||
|
### 1. **React-jsonschema-form** / **@rjsf/core**
|
||||||
|
**Что это:** Библиотека для рендеринга форм из JSON Schema
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- ✅ Готовая библиотека для React
|
||||||
|
- ✅ Поддержка валидации
|
||||||
|
- ✅ Условная логика (show/hide полей)
|
||||||
|
- ✅ Кастомизация виджетов
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- ❌ Нужно самому генерировать JSON Schema
|
||||||
|
- ❌ Не решает проблему генерации структуры
|
||||||
|
|
||||||
|
**Использование:**
|
||||||
|
```typescript
|
||||||
|
import Form from "@rjsf/core";
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
item: { type: "string", title: "Название товара" },
|
||||||
|
purchase_date: { type: "string", format: "date", title: "Дата покупки" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
<Form schema={schema} />
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вывод:** Полезно для рендеринга, но структуру всё равно нужно генерировать самим.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **Form.io** (платный сервис)
|
||||||
|
**Что это:** Платформа для создания форм с API
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- ✅ Есть API для создания форм программно
|
||||||
|
- ✅ Поддержка условной логики
|
||||||
|
- ✅ Готовые компоненты
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- ❌ Платный (от $99/месяц)
|
||||||
|
- ❌ Нет генерации структуры из описания
|
||||||
|
- ❌ Нужно самому создавать формы через API
|
||||||
|
|
||||||
|
**API пример:**
|
||||||
|
```javascript
|
||||||
|
// Создание формы через API
|
||||||
|
POST https://api.form.io/v1/form
|
||||||
|
{
|
||||||
|
"title": "Claim Form",
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"type": "textfield",
|
||||||
|
"key": "item",
|
||||||
|
"label": "Название товара"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вывод:** Дорого и не решает задачу генерации структуры.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. **Typeform API**
|
||||||
|
**Что это:** API для создания Typeform форм
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- ✅ Есть API
|
||||||
|
- ✅ Красивый UI
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- ❌ Платный (от $25/месяц)
|
||||||
|
- ❌ Нет генерации структуры
|
||||||
|
- ❌ Своя экосистема (не встраивается в наш проект)
|
||||||
|
|
||||||
|
**Вывод:** Не подходит для нашей задачи.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. **JSON Schema Form Generators**
|
||||||
|
|
||||||
|
**Библиотеки:**
|
||||||
|
- `react-jsonschema-form`
|
||||||
|
- `@rjsf/core`
|
||||||
|
- `formik` + `yup` (схемы валидации)
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- ✅ Стандарт JSON Schema
|
||||||
|
- ✅ Гибкость в описании форм
|
||||||
|
- ✅ Валидация из коробки
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- ❌ Нужно самому генерировать схему
|
||||||
|
- ❌ Не решает задачу генерации структуры
|
||||||
|
|
||||||
|
**Пример JSON Schema:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"item": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Название товара",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"purchase_date": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date",
|
||||||
|
"title": "Дата покупки"
|
||||||
|
},
|
||||||
|
"documents_available": {
|
||||||
|
"type": "array",
|
||||||
|
"title": "Какие документы есть?",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["receipt", "contract", "photos"]
|
||||||
|
},
|
||||||
|
"uniqueItems": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вывод:** Можно использовать для рендеринга, но генерацию структуры нужно делать самим.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Вывод: Нет готового решения
|
||||||
|
|
||||||
|
### Почему нет готового API?
|
||||||
|
|
||||||
|
1. **Специфичность задачи:** Генерация визарда на основе описания проблемы - это очень специфичная задача для юридической сферы
|
||||||
|
2. **Контекст:** Нужно понимать контекст дела, типы документов, требования законодательства
|
||||||
|
3. **Кастомизация:** Каждый проект имеет свои требования к структуре визарда
|
||||||
|
|
||||||
|
### Что есть:
|
||||||
|
- ✅ Библиотеки для **рендеринга** форм (React, Vue, JS)
|
||||||
|
- ✅ Сервисы для **создания** форм программно (Form.io, Typeform)
|
||||||
|
- ❌ API для **генерации структуры** визарда из описания - **НЕТ**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Рекомендации
|
||||||
|
|
||||||
|
### Вариант 1: Свой генератор (рекомендуется)
|
||||||
|
|
||||||
|
**Архитектура:**
|
||||||
|
```
|
||||||
|
Описание → ИИ (классификация) → Бэкенд (шаблоны) → JSON Schema → Фронтенд (рендеринг)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- ✅ Полный контроль
|
||||||
|
- ✅ Оптимизация под наши нужды
|
||||||
|
- ✅ Нет зависимости от внешних сервисов
|
||||||
|
- ✅ Бесплатно
|
||||||
|
|
||||||
|
**Реализация:**
|
||||||
|
1. ИИ классифицирует случай
|
||||||
|
2. Бэкенд выбирает шаблон
|
||||||
|
3. Генерируем JSON Schema или наш формат
|
||||||
|
4. Фронтенд рендерит через `react-jsonschema-form` или свой компонент
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Вариант 2: Гибридный подход
|
||||||
|
|
||||||
|
**Использовать готовые библиотеки для рендеринга:**
|
||||||
|
- `@rjsf/core` для рендеринга форм
|
||||||
|
- Свой генератор JSON Schema в бэкенде
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- ✅ Готовая валидация и UI
|
||||||
|
- ✅ Меньше кода на фронтенде
|
||||||
|
- ✅ Стандартный формат (JSON Schema)
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- ❌ Нужно адаптировать под наш формат визарда
|
||||||
|
- ❌ Может быть избыточно
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Вариант 3: Использовать Form.io (если бюджет есть)
|
||||||
|
|
||||||
|
**Если готовы платить $99+/месяц:**
|
||||||
|
- Использовать Form.io API для создания форм
|
||||||
|
- Но генерацию структуры всё равно делать самим через ИИ
|
||||||
|
|
||||||
|
**Вывод:** Не стоит того, так как генерацию структуры всё равно нужно делать самим.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Итоговая рекомендация
|
||||||
|
|
||||||
|
### Использовать свой генератор + готовые библиотеки для рендеринга
|
||||||
|
|
||||||
|
**Стек:**
|
||||||
|
1. **Генерация структуры:** Свой бэкенд (ИИ + шаблоны)
|
||||||
|
2. **Формат:** JSON Schema или наш формат
|
||||||
|
3. **Рендеринг:** `@rjsf/core` или свой компонент
|
||||||
|
|
||||||
|
**Почему:**
|
||||||
|
- ✅ Нет готовых API для генерации структуры
|
||||||
|
- ✅ Готовые библиотеки для рендеринга есть
|
||||||
|
- ✅ Полный контроль над процессом
|
||||||
|
- ✅ Оптимизация под наши нужды
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Полезные ссылки
|
||||||
|
|
||||||
|
### Библиотеки для рендеринга:
|
||||||
|
- [react-jsonschema-form](https://github.com/rjsf-team/react-jsonschema-form)
|
||||||
|
- [@rjsf/core](https://github.com/rjsf-team/react-jsonschema-form)
|
||||||
|
- [Formik](https://formik.org/) - управление формами в React
|
||||||
|
- [React Hook Form](https://react-hook-form.com/) - производительные формы
|
||||||
|
|
||||||
|
### JSON Schema:
|
||||||
|
- [JSON Schema Specification](https://json-schema.org/)
|
||||||
|
- [JSON Schema Examples](https://json-schema.org/learn/examples-guide)
|
||||||
|
|
||||||
|
### Сервисы (для справки):
|
||||||
|
- [Form.io](https://form.io/) - платный, от $99/мес
|
||||||
|
- [Typeform API](https://developer.typeform.com/) - платный, от $25/мес
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Вывод
|
||||||
|
|
||||||
|
**Готовых API для генерации структуры визарда нет.**
|
||||||
|
**Нужно делать свой генератор**, но можно использовать готовые библиотеки для рендеринга.
|
||||||
|
|
||||||
|
**Рекомендуемый подход:**
|
||||||
|
1. ИИ классифицирует случай (5-10 сек)
|
||||||
|
2. Бэкенд генерирует структуру из шаблонов (0.1 сек)
|
||||||
|
3. Фронтенд рендерит через `@rjsf/core` или свой компонент
|
||||||
|
|
||||||
|
**Это оптимальный баланс скорости, контроля и стоимости.**
|
||||||
|
|
||||||
448
docs/WIZARD_CACHING_STRATEGY.md
Normal file
448
docs/WIZARD_CACHING_STRATEGY.md
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
# Стратегия кеширования визардов
|
||||||
|
|
||||||
|
**Дата:** 2025-01-XX
|
||||||
|
**Вопрос:** Как кешировать визарды, если они всегда индивидуальные?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤔 Проблема
|
||||||
|
|
||||||
|
**Кажется, что визарды всегда индивидуальные:**
|
||||||
|
- Каждое описание проблемы уникально
|
||||||
|
- Разные детали, разные обстоятельства
|
||||||
|
- Как найти "похожий" визард?
|
||||||
|
|
||||||
|
**НО! На самом деле:**
|
||||||
|
- **Структура визарда** (вопросы, документы) часто **одинаковая** для похожих типов дел
|
||||||
|
- **Содержание** (ответы пользователя) - индивидуальное, но это не нужно кешировать
|
||||||
|
- **Типы дел** повторяются: "дефект товара", "некачественная услуга", "нарушение сроков"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Решение: Многоуровневое кеширование
|
||||||
|
|
||||||
|
### Уровень 1: Кеш по типу дела (самый быстрый)
|
||||||
|
|
||||||
|
**Идея:** Визарды для одного типа дела имеют одинаковую структуру
|
||||||
|
|
||||||
|
**Как работает:**
|
||||||
|
```python
|
||||||
|
# После генерации визарда
|
||||||
|
case_type = classification["case_type"] # "product_defect", "service_issue", etc.
|
||||||
|
|
||||||
|
# Кешируем структуру визарда (без ответов!)
|
||||||
|
cache_key = f"wizard:template:{case_type}"
|
||||||
|
redis.set(cache_key, wizard_structure, ttl=86400) # 24 часа
|
||||||
|
|
||||||
|
# При следующем запросе
|
||||||
|
if cached := redis.get(cache_key):
|
||||||
|
# Используем кеш (0.001 сек)
|
||||||
|
return cached
|
||||||
|
```
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- ✅ Мгновенно (0.001 сек)
|
||||||
|
- ✅ Просто реализовать
|
||||||
|
- ✅ Работает для 80% случаев
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- ❌ Не учитывает нюансы описания
|
||||||
|
- ❌ Может быть слишком общим
|
||||||
|
|
||||||
|
**Когда использовать:**
|
||||||
|
- Стандартные типы дел (дефект товара, некачественная услуга)
|
||||||
|
- После апрува визарда администратором
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Уровень 2: Кеш по похожести описания (семантический поиск)
|
||||||
|
|
||||||
|
**Идея:** Находим похожие описания через векторизацию
|
||||||
|
|
||||||
|
**Как работает:**
|
||||||
|
```python
|
||||||
|
# 1. Векторизуем описание проблемы
|
||||||
|
description = "Купил смартфон в DNS, через неделю сломался экран"
|
||||||
|
embedding = get_text_embedding(description) # [0.1, 0.2, ...]
|
||||||
|
|
||||||
|
# 2. Ищем похожие описания в Elasticsearch/векторной БД
|
||||||
|
similar_cases = vector_search(embedding, limit=5, min_similarity=0.85)
|
||||||
|
|
||||||
|
# 3. Если нашли похожий (similarity > 0.85)
|
||||||
|
if similar_cases:
|
||||||
|
similar_wizard = similar_cases[0]["wizard_plan"]
|
||||||
|
# Используем его структуру (можем адаптировать под текущий случай)
|
||||||
|
return adapt_wizard(similar_wizard, current_description)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Структура в БД:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"description": "Купил смартфон в DNS, через неделю сломался экран",
|
||||||
|
"description_embedding": [0.1, 0.2, ...],
|
||||||
|
"wizard_plan": {
|
||||||
|
"questions": [...],
|
||||||
|
"documents": [...]
|
||||||
|
},
|
||||||
|
"case_type": "product_defect",
|
||||||
|
"approved": true,
|
||||||
|
"created_at": "2025-01-15T10:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- ✅ Учитывает нюансы описания
|
||||||
|
- ✅ Находит действительно похожие случаи
|
||||||
|
- ✅ Можно использовать уже апрувленные визарды
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- ❌ Требует векторную БД (Elasticsearch, Pinecone, Qdrant)
|
||||||
|
- ❌ Нужна векторизация каждого описания (0.5-1 сек)
|
||||||
|
- ❌ Поиск занимает время (0.1-0.5 сек)
|
||||||
|
|
||||||
|
**Когда использовать:**
|
||||||
|
- Сложные/уникальные случаи
|
||||||
|
- После апрува визарда администратором
|
||||||
|
- Для обучения системы на удачных примерах
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Уровень 3: Кеш по хешу описания (точное совпадение)
|
||||||
|
|
||||||
|
**Идея:** Если описание точно такое же (или очень похожее) - используем кеш
|
||||||
|
|
||||||
|
**Как работает:**
|
||||||
|
```python
|
||||||
|
# 1. Вычисляем хеш описания (первые 200-300 символов)
|
||||||
|
description_hash = hashlib.md5(description[:300].encode()).hexdigest()
|
||||||
|
|
||||||
|
# 2. Проверяем кеш
|
||||||
|
cache_key = f"wizard:hash:{description_hash}"
|
||||||
|
if cached := redis.get(cache_key):
|
||||||
|
return cached # Мгновенно!
|
||||||
|
|
||||||
|
# 3. Генерируем визард
|
||||||
|
wizard = generate_wizard(description)
|
||||||
|
|
||||||
|
# 4. Сохраняем в кеш
|
||||||
|
redis.set(cache_key, wizard, ttl=3600) # 1 час
|
||||||
|
```
|
||||||
|
|
||||||
|
**Плюсы:**
|
||||||
|
- ✅ Мгновенно (0.001 сек)
|
||||||
|
- ✅ Просто реализовать
|
||||||
|
- ✅ Работает для повторных запросов
|
||||||
|
|
||||||
|
**Минусы:**
|
||||||
|
- ❌ Только для точных совпадений
|
||||||
|
- ❌ Не учитывает синонимы/перефразировки
|
||||||
|
|
||||||
|
**Когда использовать:**
|
||||||
|
- Тестирование (повторные запросы)
|
||||||
|
- Защита от дубликатов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Комбинированная стратегия (рекомендуется)
|
||||||
|
|
||||||
|
### Алгоритм:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def get_wizard_cached(description: str) -> dict:
|
||||||
|
"""
|
||||||
|
Многоуровневое кеширование визардов
|
||||||
|
"""
|
||||||
|
|
||||||
|
# УРОВЕНЬ 1: Точное совпадение (хеш)
|
||||||
|
description_hash = hashlib.md5(description[:300].encode()).hexdigest()
|
||||||
|
cache_key_hash = f"wizard:hash:{description_hash}"
|
||||||
|
if cached := await redis.get(cache_key_hash):
|
||||||
|
logger.info("✅ Cache hit: hash")
|
||||||
|
return json.loads(cached)
|
||||||
|
|
||||||
|
# УРОВЕНЬ 2: Классификация + шаблон
|
||||||
|
classification = await classify_case(description) # ИИ: 5-10 сек
|
||||||
|
case_type = classification["case_type"]
|
||||||
|
|
||||||
|
cache_key_template = f"wizard:template:{case_type}"
|
||||||
|
if cached := await redis.get(cache_key_template):
|
||||||
|
logger.info("✅ Cache hit: template")
|
||||||
|
wizard = json.loads(cached)
|
||||||
|
# Адаптируем под текущий случай (автозаполнение)
|
||||||
|
wizard = adapt_wizard(wizard, classification["extracted_data"])
|
||||||
|
return wizard
|
||||||
|
|
||||||
|
# УРОВЕНЬ 3: Семантический поиск (похожие случаи)
|
||||||
|
embedding = await get_text_embedding(description) # 0.5-1 сек
|
||||||
|
similar_cases = await vector_search(embedding, limit=3, min_similarity=0.85)
|
||||||
|
|
||||||
|
if similar_cases and similar_cases[0]["similarity"] > 0.90:
|
||||||
|
logger.info("✅ Cache hit: similar case")
|
||||||
|
wizard = similar_cases[0]["wizard_plan"]
|
||||||
|
wizard = adapt_wizard(wizard, classification["extracted_data"])
|
||||||
|
return wizard
|
||||||
|
|
||||||
|
# УРОВЕНЬ 4: Генерация нового визарда
|
||||||
|
logger.info("🔄 Generating new wizard")
|
||||||
|
wizard = await generate_wizard(description) # 30-40 сек
|
||||||
|
|
||||||
|
# Сохраняем в кеши всех уровней
|
||||||
|
await save_to_cache(wizard, description, classification, embedding)
|
||||||
|
|
||||||
|
return wizard
|
||||||
|
|
||||||
|
|
||||||
|
async def save_to_cache(wizard, description, classification, embedding):
|
||||||
|
"""Сохраняем визард во все уровни кеша"""
|
||||||
|
|
||||||
|
# 1. Хеш (точное совпадение)
|
||||||
|
description_hash = hashlib.md5(description[:300].encode()).hexdigest()
|
||||||
|
await redis.set(
|
||||||
|
f"wizard:hash:{description_hash}",
|
||||||
|
json.dumps(wizard),
|
||||||
|
ttl=3600 # 1 час
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Шаблон (по типу дела) - только если визард апрувлен
|
||||||
|
# (это делается вручную администратором)
|
||||||
|
|
||||||
|
# 3. Векторная БД (для семантического поиска)
|
||||||
|
await vector_db.insert({
|
||||||
|
"description": description,
|
||||||
|
"description_embedding": embedding,
|
||||||
|
"wizard_plan": wizard,
|
||||||
|
"case_type": classification["case_type"],
|
||||||
|
"approved": False, # Станет True после апрува
|
||||||
|
"created_at": datetime.now().isoformat()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Когда что использовать
|
||||||
|
|
||||||
|
### Сценарий 1: Первый запрос (нет кеша)
|
||||||
|
```
|
||||||
|
Описание → Классификация (5-10 сек) → Генерация (30-40 сек) → Сохранение в кеш
|
||||||
|
```
|
||||||
|
**Время:** 35-50 секунд
|
||||||
|
|
||||||
|
### Сценарий 2: Повторный запрос (точное совпадение)
|
||||||
|
```
|
||||||
|
Описание → Хеш → Redis → Визард
|
||||||
|
```
|
||||||
|
**Время:** 0.001 секунды ⚡
|
||||||
|
|
||||||
|
### Сценарий 3: Похожий тип дела (шаблон)
|
||||||
|
```
|
||||||
|
Описание → Классификация (5-10 сек) → Redis (шаблон) → Адаптация → Визард
|
||||||
|
```
|
||||||
|
**Время:** 5-10 секунд ⚡⚡
|
||||||
|
|
||||||
|
### Сценарий 4: Похожее описание (семантический поиск)
|
||||||
|
```
|
||||||
|
Описание → Векторизация (0.5-1 сек) → Поиск (0.1-0.5 сек) → Адаптация → Визард
|
||||||
|
```
|
||||||
|
**Время:** 0.6-1.5 секунды ⚡⚡⚡
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Апрув визарда администратором
|
||||||
|
|
||||||
|
### Что происходит после апрува:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def approve_wizard(wizard_id: str):
|
||||||
|
"""
|
||||||
|
Администратор апрувит визард
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 1. Получаем визард из БД
|
||||||
|
wizard = await db.get_wizard(wizard_id)
|
||||||
|
|
||||||
|
# 2. Сохраняем как шаблон для этого типа дела
|
||||||
|
case_type = wizard["case_type"]
|
||||||
|
await redis.set(
|
||||||
|
f"wizard:template:{case_type}",
|
||||||
|
json.dumps(wizard["wizard_plan"]),
|
||||||
|
ttl=None # Без срока (пока не обновим)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Помечаем в векторной БД как апрувленный
|
||||||
|
await vector_db.update(wizard_id, {"approved": True})
|
||||||
|
|
||||||
|
# 4. Теперь этот визард будет использоваться для всех похожих случаев
|
||||||
|
```
|
||||||
|
|
||||||
|
**Результат:**
|
||||||
|
- ✅ Все новые случаи этого типа будут использовать этот шаблон
|
||||||
|
- ✅ Время генерации: 5-10 сек (только классификация) вместо 30-40 сек
|
||||||
|
- ✅ Качество: гарантированно хороший визард (проверен администратором)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗄️ Структура хранения
|
||||||
|
|
||||||
|
### Redis (быстрый кеш):
|
||||||
|
```
|
||||||
|
wizard:hash:{md5_hash} → Визард (TTL: 1 час)
|
||||||
|
wizard:template:{case_type} → Шаблон визарда (без TTL, обновляется вручную)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Векторная БД (Elasticsearch/Pinecone/Qdrant):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "wizard_123",
|
||||||
|
"description": "Купил смартфон...",
|
||||||
|
"description_embedding": [0.1, 0.2, ...],
|
||||||
|
"wizard_plan": {
|
||||||
|
"questions": [...],
|
||||||
|
"documents": [...]
|
||||||
|
},
|
||||||
|
"case_type": "product_defect",
|
||||||
|
"approved": true,
|
||||||
|
"created_at": "2025-01-15T10:00:00Z",
|
||||||
|
"approved_at": "2025-01-15T11:00:00Z",
|
||||||
|
"approved_by": "admin@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PostgreSQL (постоянное хранение):
|
||||||
|
```sql
|
||||||
|
CREATE TABLE wizard_cache (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
description TEXT,
|
||||||
|
description_hash VARCHAR(64),
|
||||||
|
case_type VARCHAR(50),
|
||||||
|
wizard_plan JSONB,
|
||||||
|
embedding VECTOR(1024), -- pgvector
|
||||||
|
approved BOOLEAN DEFAULT FALSE,
|
||||||
|
approved_at TIMESTAMP,
|
||||||
|
approved_by VARCHAR(255),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
usage_count INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_wizard_hash ON wizard_cache(description_hash);
|
||||||
|
CREATE INDEX idx_wizard_case_type ON wizard_cache(case_type);
|
||||||
|
CREATE INDEX idx_wizard_approved ON wizard_cache(approved) WHERE approved = TRUE;
|
||||||
|
CREATE INDEX idx_wizard_embedding ON wizard_cache USING ivfflat (embedding vector_cosine_ops);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Реализация
|
||||||
|
|
||||||
|
### Шаг 1: Добавить векторизацию описания
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ticket_form/backend/app/services/embedding_service.py
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
class EmbeddingService:
|
||||||
|
async def get_embedding(self, text: str) -> list[float]:
|
||||||
|
"""Векторизация текста через OpenAI"""
|
||||||
|
client = OpenAI(api_key=settings.openai_api_key)
|
||||||
|
response = client.embeddings.create(
|
||||||
|
model="text-embedding-3-small", # Быстрая и дешёвая модель
|
||||||
|
input=text[:8000] # Ограничение длины
|
||||||
|
)
|
||||||
|
return response.data[0].embedding
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 2: Добавить векторный поиск
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ticket_form/backend/app/services/wizard_cache_service.py
|
||||||
|
class WizardCacheService:
|
||||||
|
async def find_similar_wizards(
|
||||||
|
self,
|
||||||
|
embedding: list[float],
|
||||||
|
limit: int = 5,
|
||||||
|
min_similarity: float = 0.85
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Поиск похожих визардов через векторный поиск"""
|
||||||
|
|
||||||
|
# Используем Elasticsearch (уже есть в проекте!)
|
||||||
|
query = {
|
||||||
|
"size": limit,
|
||||||
|
"query": {
|
||||||
|
"script_score": {
|
||||||
|
"query": {"match_all": {}},
|
||||||
|
"script": {
|
||||||
|
"source": "cosineSimilarity(params.query_vector, 'description_embedding') + 1.0",
|
||||||
|
"params": {"query_vector": embedding}
|
||||||
|
},
|
||||||
|
"min_score": min_similarity + 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results = await elasticsearch.search(
|
||||||
|
index="wizard_cache",
|
||||||
|
body=query
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"wizard_plan": hit["_source"]["wizard_plan"],
|
||||||
|
"similarity": hit["_score"] - 1.0, # Нормализуем
|
||||||
|
"case_type": hit["_source"]["case_type"]
|
||||||
|
}
|
||||||
|
for hit in results["hits"]["hits"]
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 3: Интегрировать в генерацию визарда
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ticket_form/backend/app/api/claims.py
|
||||||
|
@router.post("/wizard/generate")
|
||||||
|
async def generate_wizard(request: Request):
|
||||||
|
description = (await request.json())["description"]
|
||||||
|
|
||||||
|
# Многоуровневое кеширование
|
||||||
|
wizard = await wizard_cache_service.get_wizard_cached(description)
|
||||||
|
|
||||||
|
return {"wizard_plan": wizard}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Ожидаемые результаты
|
||||||
|
|
||||||
|
### До кеширования:
|
||||||
|
- **Время:** 30-40 секунд для каждого запроса
|
||||||
|
- **Нагрузка:** Высокая (каждый раз обращение к ИИ)
|
||||||
|
|
||||||
|
### После кеширования:
|
||||||
|
- **Первый запрос:** 30-40 секунд (генерация)
|
||||||
|
- **Повторный запрос:** 0.001 секунды (хеш) ⚡
|
||||||
|
- **Похожий тип дела:** 5-10 секунд (шаблон) ⚡⚡
|
||||||
|
- **Похожее описание:** 0.6-1.5 секунды (семантический поиск) ⚡⚡⚡
|
||||||
|
|
||||||
|
### Экономия:
|
||||||
|
- **80% запросов** будут из кеша (0.001-10 сек вместо 30-40 сек)
|
||||||
|
- **Снижение нагрузки** на ИИ в 5-10 раз
|
||||||
|
- **Улучшение UX:** Пользователи получают визарды мгновенно
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Вывод
|
||||||
|
|
||||||
|
**Визарды не всегда индивидуальные!**
|
||||||
|
|
||||||
|
1. **Структура визарда** (вопросы, документы) повторяется для похожих типов дел
|
||||||
|
2. **Содержание** (ответы) - индивидуальное, но его не нужно кешировать
|
||||||
|
3. **Многоуровневое кеширование** позволяет использовать готовые визарды для похожих случаев
|
||||||
|
|
||||||
|
**Стратегия:**
|
||||||
|
- Кеш по хешу (точное совпадение) → 0.001 сек
|
||||||
|
- Кеш по типу дела (шаблон) → 5-10 сек
|
||||||
|
- Семантический поиск (похожие описания) → 0.6-1.5 сек
|
||||||
|
- Генерация нового → 30-40 сек (только если нет кеша)
|
||||||
|
|
||||||
|
**После апрува администратором:**
|
||||||
|
- Визард становится шаблоном для этого типа дела
|
||||||
|
- Все новые случаи используют этот шаблон (5-10 сек вместо 30-40 сек)
|
||||||
|
|
||||||
55
docs/WIZARD_OPTIMIZATION.md
Normal file
55
docs/WIZARD_OPTIMIZATION.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Оптимизация генерации визарда
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
AI Agent генерирует визард за ~40 секунд, что слишком долго для UX.
|
||||||
|
|
||||||
|
## Варианты оптимизации
|
||||||
|
|
||||||
|
### 1. Сократить промпт (приоритет: ВЫСОКИЙ)
|
||||||
|
Текущий промпт ~2000+ символов. Можно сократить до ~800-1000, убрав:
|
||||||
|
- Повторения инструкций
|
||||||
|
- Детальные объяснения форматов (оставить только примеры)
|
||||||
|
- Лишние поля в ответе (если не используются)
|
||||||
|
|
||||||
|
**Ожидаемый эффект:** -15-20 секунд
|
||||||
|
|
||||||
|
### 2. Использовать более быструю модель
|
||||||
|
- `gpt-4o-mini` вместо `gpt-4.1-mini` (быстрее в 2-3 раза)
|
||||||
|
- Или `gpt-3.5-turbo` для простых случаев
|
||||||
|
|
||||||
|
**Ожидаемый эффект:** -20-25 секунд
|
||||||
|
|
||||||
|
### 3. Streaming ответа
|
||||||
|
Начать обрабатывать JSON по частям, как только начинают приходить данные.
|
||||||
|
|
||||||
|
**Ожидаемый эффект:** UX улучшится (показываем прогресс), но общее время не изменится
|
||||||
|
|
||||||
|
### 4. Кэширование для похожих запросов
|
||||||
|
Кэшировать результаты для похожих описаний (по хэшу первых 200 символов).
|
||||||
|
|
||||||
|
**Ожидаемый эффект:** -35-40 секунд для повторных запросов
|
||||||
|
|
||||||
|
### 5. Упростить схему ответа
|
||||||
|
Убрать неиспользуемые поля:
|
||||||
|
- `coverage_report.questions` (если не используется)
|
||||||
|
- `risks`, `deadlines` (если не критично)
|
||||||
|
- Детальные `rationale` для каждого вопроса
|
||||||
|
|
||||||
|
**Ожидаемый эффект:** -5-10 секунд
|
||||||
|
|
||||||
|
### 6. Разбить на этапы
|
||||||
|
1. Быстро генерировать базовый план (5-7 вопросов, список документов) - 10-15 сек
|
||||||
|
2. Параллельно/асинхронно дорабатывать prefill и coverage_report
|
||||||
|
|
||||||
|
**Ожидаемый эффект:** UX улучшится (показываем план быстрее)
|
||||||
|
|
||||||
|
## Рекомендуемый подход
|
||||||
|
|
||||||
|
**Комбинация 1 + 2 + 5:**
|
||||||
|
- Сократить промпт до минимума
|
||||||
|
- Переключиться на `gpt-4o-mini`
|
||||||
|
- Убрать неиспользуемые поля
|
||||||
|
|
||||||
|
**Ожидаемый результат:** 40 сек → 10-15 сек
|
||||||
|
|
||||||
|
|
||||||
264
docs/WIZARD_OPTIMIZATION_ANALYSIS.md
Normal file
264
docs/WIZARD_OPTIMIZATION_ANALYSIS.md
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
# Анализ оптимизации генерации визарда
|
||||||
|
|
||||||
|
**Дата:** 2025-01-XX
|
||||||
|
**Текущее время генерации:** ~30-40 секунд
|
||||||
|
**Цель:** Сократить до 5-15 секунд
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Вариант 1: Двухэтапный подход (твоя идея)
|
||||||
|
|
||||||
|
### Концепция:
|
||||||
|
1. **ИИ только классифицирует** случай и выдаёт список нужных документов/полей
|
||||||
|
2. **Бэкенд строит визард** по шаблонам на основе классификации
|
||||||
|
|
||||||
|
### Архитектура:
|
||||||
|
|
||||||
|
```
|
||||||
|
Описание → ИИ (классификация) → Бэкенд (шаблоны) → Визард
|
||||||
|
```
|
||||||
|
|
||||||
|
**ИИ возвращает:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"case_type": "product_defect", // или "service_issue", "delay", "conflict"
|
||||||
|
"required_fields": ["item", "purchase_date", "purchase_amount", "warranty_info"],
|
||||||
|
"required_documents": ["contract", "payment", "photos"],
|
||||||
|
"optional_documents": ["correspondence", "diagnosis"],
|
||||||
|
"extracted_data": {
|
||||||
|
"item": "Смартфон",
|
||||||
|
"seller": "DNS",
|
||||||
|
"purchase_date": "2024-12-15"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Бэкенд использует шаблоны:**
|
||||||
|
```python
|
||||||
|
WIZARD_TEMPLATES = {
|
||||||
|
"product_defect": {
|
||||||
|
"questions": [
|
||||||
|
{"name": "item", "label": "Как называется товар?", ...},
|
||||||
|
{"name": "purchase_date", "label": "Когда купили?", "control": "input[type=\"date\"]", ...},
|
||||||
|
{"name": "purchase_amount", "label": "Сколько стоил?", ...},
|
||||||
|
{"name": "warranty_info", "label": "Есть ли гарантия?", ...},
|
||||||
|
{"name": "problem_description", "label": "Опишите проблему", "control": "textarea", ...},
|
||||||
|
{"name": "documents_available", "label": "Какие документы есть?", "control": "input[type=\"checkbox\"]", ...},
|
||||||
|
{"name": "desired_outcome", "label": "Что хотите получить?", "control": "input[type=\"radio\"]", ...}
|
||||||
|
],
|
||||||
|
"documents": [
|
||||||
|
{"id": "contract", "name": "Договор", "required": true, ...},
|
||||||
|
{"id": "payment", "name": "Чек", "required": true, ...},
|
||||||
|
{"id": "photos", "name": "Фото дефекта", "required": true, ...}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"service_issue": { ... },
|
||||||
|
"delay": { ... },
|
||||||
|
"conflict": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Плюсы:
|
||||||
|
✅ **Скорость:** ИИ только классифицирует (5-10 сек) + бэкенд мгновенно (0.1 сек) = **5-10 сек всего**
|
||||||
|
✅ **Предсказуемость:** Визарды всегда структурированы одинаково
|
||||||
|
✅ **Контроль:** Легко менять вопросы/документы без изменения промпта
|
||||||
|
✅ **Кеширование:** Можно кешировать шаблоны в памяти
|
||||||
|
✅ **Тестирование:** Легко тестировать шаблоны отдельно от ИИ
|
||||||
|
|
||||||
|
### Минусы:
|
||||||
|
❌ **Гибкость:** Сложные/уникальные случаи могут не попасть в шаблоны
|
||||||
|
❌ **Разработка:** Нужно создать и поддерживать библиотеку шаблонов
|
||||||
|
❌ **Классификация:** ИИ должен точно определить тип дела
|
||||||
|
|
||||||
|
### Реализация:
|
||||||
|
1. Создать `wizard_templates.py` в бэкенде с шаблонами
|
||||||
|
2. Упростить промпт для ИИ (только классификация + список полей/документов)
|
||||||
|
3. Создать `WizardBuilder` сервис, который собирает визард из шаблона
|
||||||
|
4. Обновить n8n workflow для упрощённого ответа
|
||||||
|
|
||||||
|
**Ожидаемое время:** 5-10 секунд
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Вариант 2: Гибридный подход
|
||||||
|
|
||||||
|
### Концепция:
|
||||||
|
1. **ИИ классифицирует** и выдаёт список полей/документов (быстро)
|
||||||
|
2. **Бэкенд использует шаблоны** для стандартных случаев
|
||||||
|
3. **ИИ достраивает** уникальные вопросы для сложных случаев (опционально)
|
||||||
|
|
||||||
|
### Плюсы:
|
||||||
|
✅ **Баланс:** Скорость + гибкость
|
||||||
|
✅ **Fallback:** Если шаблон не подходит, ИИ достраивает
|
||||||
|
|
||||||
|
### Минусы:
|
||||||
|
❌ **Сложность:** Нужно решать, когда использовать шаблон, а когда ИИ
|
||||||
|
|
||||||
|
**Ожидаемое время:** 5-15 секунд (зависит от сложности)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ Вариант 3: Кеширование готовых визардов
|
||||||
|
|
||||||
|
### Концепция:
|
||||||
|
1. **Кешировать** готовые визарды по типу дела
|
||||||
|
2. **ИИ только извлекает** данные из описания для автозаполнения
|
||||||
|
|
||||||
|
### Плюсы:
|
||||||
|
✅ **Максимальная скорость:** 1-2 секунды для стандартных случаев
|
||||||
|
✅ **Простота:** Минимальные изменения в коде
|
||||||
|
|
||||||
|
### Минусы:
|
||||||
|
❌ **Ограниченность:** Только для типовых случаев
|
||||||
|
❌ **Хранение:** Нужно хранить кеш визардов
|
||||||
|
|
||||||
|
**Ожидаемое время:** 1-2 секунды (кеш) или 30 сек (первый раз)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔥 Вариант 4: Упрощение промпта + быстрая модель
|
||||||
|
|
||||||
|
### Концепция:
|
||||||
|
1. **Сократить промпт** до минимума (убрать примеры, оставить только структуру)
|
||||||
|
2. **Использовать `gpt-4o-mini`** вместо `gpt-4.1-mini`
|
||||||
|
3. **Убрать неиспользуемые поля** из ответа
|
||||||
|
|
||||||
|
### Плюсы:
|
||||||
|
✅ **Простота:** Минимальные изменения
|
||||||
|
✅ **Скорость:** 10-15 секунд
|
||||||
|
|
||||||
|
### Минусы:
|
||||||
|
❌ **Качество:** Может снизиться качество визардов
|
||||||
|
❌ **Ограничение:** Всё ещё зависит от скорости ИИ
|
||||||
|
|
||||||
|
**Ожидаемое время:** 10-15 секунд
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Вариант 5: Предгенерированные шаблоны + ИИ только для извлечения
|
||||||
|
|
||||||
|
### Концепция:
|
||||||
|
1. **Все визарды предгенерированы** в бэкенде (шаблоны)
|
||||||
|
2. **ИИ только извлекает** данные из описания для автозаполнения
|
||||||
|
3. **Бэкенд выбирает** подходящий шаблон на основе ключевых слов
|
||||||
|
|
||||||
|
### Плюсы:
|
||||||
|
✅ **Максимальная скорость:** 1-3 секунды
|
||||||
|
✅ **Предсказуемость:** Всегда одинаковые визарды
|
||||||
|
|
||||||
|
### Минусы:
|
||||||
|
❌ **Ограниченность:** Только для типовых случаев
|
||||||
|
❌ **Классификация:** Нужна простая классификация (можно без ИИ)
|
||||||
|
|
||||||
|
**Ожидаемое время:** 1-3 секунды
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Сравнение вариантов
|
||||||
|
|
||||||
|
| Вариант | Время | Гибкость | Сложность | Рекомендация |
|
||||||
|
|---------|------|----------|-----------|--------------|
|
||||||
|
| **1. Двухэтапный** | 5-10 сек | ⭐⭐⭐⭐ | ⭐⭐⭐ | ✅ **Лучший баланс** |
|
||||||
|
| **2. Гибридный** | 5-15 сек | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ✅ Хорошо для сложных случаев |
|
||||||
|
| **3. Кеширование** | 1-2 сек | ⭐⭐ | ⭐⭐ | ✅ Для типовых случаев |
|
||||||
|
| **4. Упрощение** | 10-15 сек | ⭐⭐⭐⭐ | ⭐ | ✅ Быстрая реализация |
|
||||||
|
| **5. Предгенерированные** | 1-3 сек | ⭐⭐ | ⭐⭐ | ✅ Для простых случаев |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Рекомендация
|
||||||
|
|
||||||
|
### Для начала: **Вариант 1 (Двухэтапный)**
|
||||||
|
|
||||||
|
**Почему:**
|
||||||
|
1. **Оптимальный баланс** скорости и гибкости
|
||||||
|
2. **Масштабируемость:** Легко добавлять новые типы дел
|
||||||
|
3. **Контроль:** Все визарды структурированы и предсказуемы
|
||||||
|
4. **Тестируемость:** Шаблоны легко тестировать
|
||||||
|
|
||||||
|
### План реализации:
|
||||||
|
|
||||||
|
#### Этап 1: Классификация (ИИ)
|
||||||
|
```python
|
||||||
|
# Упрощённый промпт для ИИ
|
||||||
|
"""
|
||||||
|
Проанализируй описание проблемы и определи:
|
||||||
|
1. Тип дела (product_defect, service_issue, delay, conflict, other)
|
||||||
|
2. Какие поля нужно собрать (item, purchase_date, purchase_amount, warranty_info, ...)
|
||||||
|
3. Какие документы нужны (contract, payment, photos, correspondence, ...)
|
||||||
|
4. Что уже известно из описания (для автозаполнения)
|
||||||
|
|
||||||
|
Верни JSON:
|
||||||
|
{
|
||||||
|
"case_type": "product_defect",
|
||||||
|
"required_fields": ["item", "purchase_date", "purchase_amount", "warranty_info"],
|
||||||
|
"required_documents": ["contract", "payment", "photos"],
|
||||||
|
"optional_documents": ["correspondence"],
|
||||||
|
"extracted_data": {
|
||||||
|
"item": "Смартфон",
|
||||||
|
"seller": "DNS"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Этап 2: Шаблоны (Бэкенд)
|
||||||
|
```python
|
||||||
|
# ticket_form/backend/app/services/wizard_builder.py
|
||||||
|
class WizardBuilder:
|
||||||
|
TEMPLATES = {
|
||||||
|
"product_defect": {
|
||||||
|
"questions": [...],
|
||||||
|
"documents": [...]
|
||||||
|
},
|
||||||
|
"service_issue": {...},
|
||||||
|
"delay": {...},
|
||||||
|
"conflict": {...}
|
||||||
|
}
|
||||||
|
|
||||||
|
def build_wizard(self, classification: dict) -> dict:
|
||||||
|
template = self.TEMPLATES[classification["case_type"]]
|
||||||
|
# Собираем визард из шаблона
|
||||||
|
# Добавляем автозаполнение из extracted_data
|
||||||
|
return wizard_plan
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Этап 3: Интеграция
|
||||||
|
- Обновить n8n workflow для упрощённого ответа
|
||||||
|
- Создать эндпоинт `/api/v1/wizard/build` в бэкенде
|
||||||
|
- Обновить фронтенд для работы с новым форматом
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Дополнительные идеи
|
||||||
|
|
||||||
|
### 1. Параллельная обработка
|
||||||
|
- ИИ классифицирует
|
||||||
|
- Параллельно бэкенд готовит шаблоны
|
||||||
|
- Собираем результат
|
||||||
|
|
||||||
|
### 2. Инкрементальная генерация
|
||||||
|
- Сначала показываем базовые вопросы (из шаблона)
|
||||||
|
- Потом достраиваем уникальные (если нужно)
|
||||||
|
|
||||||
|
### 3. Умное кеширование
|
||||||
|
- Кешировать классификации по хешу описания
|
||||||
|
- Кешировать готовые визарды по типу дела
|
||||||
|
|
||||||
|
### 4. Предзагрузка шаблонов
|
||||||
|
- Загружать шаблоны в память при старте
|
||||||
|
- Не обращаться к БД/файлам каждый раз
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Следующие шаги
|
||||||
|
|
||||||
|
1. **Создать шаблоны** для основных типов дел (5-7 типов)
|
||||||
|
2. **Упростить промпт** для классификации
|
||||||
|
3. **Реализовать WizardBuilder** в бэкенде
|
||||||
|
4. **Обновить n8n workflow**
|
||||||
|
5. **Протестировать** на реальных случаях
|
||||||
|
6. **Измерить скорость** и сравнить с текущей
|
||||||
|
|
||||||
|
**Ожидаемый результат:** 5-10 секунд вместо 30-40 секунд
|
||||||
|
|
||||||
58
docs/WIZARD_SPEEDUP_GUIDE.md
Normal file
58
docs/WIZARD_SPEEDUP_GUIDE.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Как ускорить генерацию визарда с 40 до 10-15 секунд
|
||||||
|
|
||||||
|
## Быстрое решение (рекомендуется)
|
||||||
|
|
||||||
|
### Шаг 1: Заменить модель
|
||||||
|
В ноде `OpenAI Chat Model3`:
|
||||||
|
- **Было:** `gpt-4.1-mini-2025-04-14`
|
||||||
|
- **Стало:** `gpt-4o-mini`
|
||||||
|
|
||||||
|
**Эффект:** -20-25 секунд (40 сек → 15-20 сек)
|
||||||
|
|
||||||
|
### Шаг 2: Сократить промпт
|
||||||
|
Заменить промпт в `AI Agent3` на оптимизированную версию из `optimized_wizard_prompt.txt`
|
||||||
|
|
||||||
|
**Эффект:** -10-15 секунд (15-20 сек → 10-15 сек)
|
||||||
|
|
||||||
|
### Шаг 3: Добавить настройки модели
|
||||||
|
В `OpenAI Chat Model3` → `Options`:
|
||||||
|
- `temperature`: `0.3` (меньше креативности = быстрее)
|
||||||
|
- `maxTokens`: `2000` (ограничить длину ответа)
|
||||||
|
|
||||||
|
**Эффект:** -2-5 секунд
|
||||||
|
|
||||||
|
## Итого
|
||||||
|
**40 секунд → 10-15 секунд** (ускорение в 2.5-4 раза)
|
||||||
|
|
||||||
|
## Дополнительные оптимизации (опционально)
|
||||||
|
|
||||||
|
### Кэширование похожих запросов
|
||||||
|
Добавить ноду перед AI Agent:
|
||||||
|
1. Вычислить хэш первых 200 символов `chatInput`
|
||||||
|
2. Проверить Redis: есть ли кэш для этого хэша
|
||||||
|
3. Если есть — вернуть из кэша (0 сек)
|
||||||
|
4. Если нет — запустить AI Agent и сохранить результат в кэш на 1 час
|
||||||
|
|
||||||
|
**Эффект:** Для повторных/похожих запросов — мгновенный ответ
|
||||||
|
|
||||||
|
### Streaming (для UX)
|
||||||
|
Если n8n поддерживает streaming:
|
||||||
|
- Начать обрабатывать JSON по частям
|
||||||
|
- Показывать прогресс пользователю
|
||||||
|
|
||||||
|
**Эффект:** UX улучшится, но общее время не изменится
|
||||||
|
|
||||||
|
## Проверка результата
|
||||||
|
После применения оптимизаций:
|
||||||
|
1. Откройте форму
|
||||||
|
2. Введите описание проблемы
|
||||||
|
3. Засеките время до появления плана вопросов
|
||||||
|
4. Должно быть 10-15 секунд вместо 40
|
||||||
|
|
||||||
|
## Откат изменений
|
||||||
|
Если что-то пошло не так:
|
||||||
|
1. Верните модель `gpt-4.1-mini-2025-04-14`
|
||||||
|
2. Верните старый промпт
|
||||||
|
3. Уберите настройки `temperature` и `maxTokens`
|
||||||
|
|
||||||
|
|
||||||
211
docs/WORKFLOW_ANALYSIS.md
Normal file
211
docs/WORKFLOW_ANALYSIS.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# Анализ workflow 8ZVMTsuH7Cmw7snw и предложения
|
||||||
|
|
||||||
|
## Текущая структура
|
||||||
|
|
||||||
|
### Основные ноды PostgreSQL:
|
||||||
|
|
||||||
|
1. **`claimsave`** (строка 190-210)
|
||||||
|
- Использует обновленный SQL с `$2::text` (строка claim_id)
|
||||||
|
- **ПРОБЛЕМА**: SQL запрос не использует `claim_final` CTE, который я добавил в исправленной версии
|
||||||
|
- Это основная нода для сохранения данных визарда
|
||||||
|
|
||||||
|
2. **`claimsave_final`** (строка 428-450)
|
||||||
|
- Использует другой SQL запрос с `$2::uuid`
|
||||||
|
- Используется после конвертации файлов в PDF
|
||||||
|
- **ПРОБЛЕМА**: Ожидает UUID, но может получать строку
|
||||||
|
|
||||||
|
3. **`claimsave1`** (строка 634-655)
|
||||||
|
- Использует старый SQL запрос с `$2::uuid`
|
||||||
|
- **ПРОБЛЕМА**: Не работает со строковым claim_id
|
||||||
|
|
||||||
|
## Проблемы
|
||||||
|
|
||||||
|
### 1. SQL запрос в `claimsave` неполный
|
||||||
|
|
||||||
|
Текущий SQL в ноде `claimsave`:
|
||||||
|
- ✅ Использует `$2::text` (правильно)
|
||||||
|
- ✅ Имеет `claim_lookup` и `claim_created` CTE
|
||||||
|
- ❌ **НЕ использует `claim_final` CTE** (который я добавил в исправленной версии)
|
||||||
|
- ❌ Использует `claim_lookup.claim_uuid` напрямую, что может не работать, если запись была создана в `claim_created`
|
||||||
|
|
||||||
|
### 2. Несоответствие типов данных
|
||||||
|
|
||||||
|
- `claimsave` ожидает строку (`$2::text`)
|
||||||
|
- `claimsave_final` ожидает UUID (`$2::uuid`)
|
||||||
|
- `claimsave1` ожидает UUID (`$2::uuid`)
|
||||||
|
|
||||||
|
Но везде передается `claim_id` как строка `"CLM-2025-11-18-GEQ3KL"`.
|
||||||
|
|
||||||
|
### 3. Проблема с `existing` CTE
|
||||||
|
|
||||||
|
В текущем SQL запросе `existing` может не найти запись, если она была создана в `claim_created`, потому что:
|
||||||
|
- `claim_lookup` выполняется ДО `claim_created`
|
||||||
|
- `existing` использует `claim_lookup.claim_uuid`, но запись может быть создана в `claim_created`
|
||||||
|
|
||||||
|
## Решения
|
||||||
|
|
||||||
|
### Решение 1: Обновить SQL в ноде `claimsave`
|
||||||
|
|
||||||
|
Заменить SQL запрос на исправленную версию из `FIXED_SQL_QUERY.md`:
|
||||||
|
|
||||||
|
**Ключевые изменения:**
|
||||||
|
1. Добавить `claim_final` CTE для получения правильного UUID
|
||||||
|
2. Использовать `claim_final.claim_uuid` вместо `claim_lookup.claim_uuid`
|
||||||
|
3. Исправить `old` CTE, чтобы он всегда возвращал строку
|
||||||
|
|
||||||
|
### Решение 2: Унифицировать типы данных
|
||||||
|
|
||||||
|
**Вариант A**: Все ноды используют строку `claim_id`
|
||||||
|
- Изменить `claimsave_final` и `claimsave1` на `$2::text`
|
||||||
|
- Добавить логику поиска UUID по строке `claim_id`
|
||||||
|
|
||||||
|
**Вариант B**: Все ноды используют UUID
|
||||||
|
- Перед SQL запросами добавить Code Node, который:
|
||||||
|
- Находит запись в `clpr_claims` по `payload->>'claim_id'`
|
||||||
|
- Извлекает её `id` (UUID)
|
||||||
|
- Передает UUID в SQL запрос
|
||||||
|
|
||||||
|
**Рекомендую Вариант A** (использовать строку везде), т.к.:
|
||||||
|
- Проще реализовать
|
||||||
|
- Меньше изменений в workflow
|
||||||
|
- `claim_id` в формате `CLM-YYYY-MM-DD-XXXXXX` - это основной идентификатор
|
||||||
|
|
||||||
|
### Решение 3: Упростить логику
|
||||||
|
|
||||||
|
Можно упростить SQL запрос, убрав сложную логику слияния:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
WITH partial AS (
|
||||||
|
SELECT $1::jsonb AS p, $2::text AS claim_id_str
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Находим или создаем запись
|
||||||
|
claim_final AS (
|
||||||
|
SELECT
|
||||||
|
COALESCE(
|
||||||
|
(SELECT id FROM clpr_claims WHERE payload->>'claim_id' = partial.claim_id_str LIMIT 1),
|
||||||
|
gen_random_uuid()
|
||||||
|
) AS claim_uuid
|
||||||
|
FROM partial
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Создаем запись, если её нет
|
||||||
|
claim_created AS (
|
||||||
|
INSERT INTO clpr_claims (
|
||||||
|
id, session_token, channel, type_code, status_code, payload, created_at, updated_at, expires_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
claim_final.claim_uuid,
|
||||||
|
COALESCE(partial.p->>'session_id', 'sess-' || gen_random_uuid()::text),
|
||||||
|
'web_form',
|
||||||
|
COALESCE(partial.p->>'type_code', 'consumer'),
|
||||||
|
'draft',
|
||||||
|
jsonb_build_object(
|
||||||
|
'claim_id', partial.claim_id_str,
|
||||||
|
'answers', COALESCE(partial.p->'answers', '{}'::jsonb),
|
||||||
|
'documents_meta', COALESCE(partial.p->'documents_meta', '[]'::jsonb),
|
||||||
|
'wizard_plan', partial.p->'wizard_plan',
|
||||||
|
'wizard_answers', partial.p->'wizard_answers',
|
||||||
|
'form_data', partial.p
|
||||||
|
),
|
||||||
|
now(), now(), now() + interval '14 days'
|
||||||
|
FROM partial, claim_final
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM clpr_claims WHERE id = claim_final.claim_uuid)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Сохраняем документы
|
||||||
|
inserted_docs AS (
|
||||||
|
INSERT INTO clpr_claim_documents
|
||||||
|
(claim_id, field_name, file_id, uploaded_at, file_name, original_file_name)
|
||||||
|
SELECT
|
||||||
|
claim_final.claim_uuid::text,
|
||||||
|
doc.field_name, doc.file_id,
|
||||||
|
(doc.uploaded_at)::timestamptz,
|
||||||
|
doc.file_name, doc.original_file_name
|
||||||
|
FROM partial, claim_final
|
||||||
|
CROSS JOIN LATERAL jsonb_to_recordset(
|
||||||
|
COALESCE(partial.p->'documents_meta','[]'::jsonb)
|
||||||
|
) AS doc(field_name text, file_id text, file_name text, original_file_name text, uploaded_at text)
|
||||||
|
ON CONFLICT (claim_id, field_name) DO UPDATE
|
||||||
|
SET file_id = EXCLUDED.file_id,
|
||||||
|
uploaded_at = EXCLUDED.uploaded_at,
|
||||||
|
file_name = EXCLUDED.file_name,
|
||||||
|
original_file_name = EXCLUDED.original_file_name
|
||||||
|
RETURNING id, claim_id, field_name, file_id
|
||||||
|
),
|
||||||
|
|
||||||
|
-- Обновляем запись (простое слияние)
|
||||||
|
upd AS (
|
||||||
|
UPDATE clpr_claims c
|
||||||
|
SET
|
||||||
|
payload = COALESCE(c.payload, '{}'::jsonb) || partial.p,
|
||||||
|
status_code = CASE
|
||||||
|
WHEN (partial.p->'answers'->>'docs_exist' = 'true') THEN 'in_work'
|
||||||
|
ELSE COALESCE(c.status_code, 'draft')
|
||||||
|
END,
|
||||||
|
updated_at = now(),
|
||||||
|
expires_at = now() + interval '14 days'
|
||||||
|
FROM partial, claim_final
|
||||||
|
WHERE c.id = claim_final.claim_uuid
|
||||||
|
RETURNING c.id, c.status_code, c.payload
|
||||||
|
)
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
(SELECT jsonb_build_object(
|
||||||
|
'claim_id', u.id::text,
|
||||||
|
'claim_id_str', (u.payload->>'claim_id'),
|
||||||
|
'status_code', u.status_code,
|
||||||
|
'payload', u.payload
|
||||||
|
) FROM upd u LIMIT 1) AS claim,
|
||||||
|
(SELECT jsonb_agg(jsonb_build_object(
|
||||||
|
'id', id,
|
||||||
|
'field_name', field_name,
|
||||||
|
'file_id', file_id
|
||||||
|
)) FROM inserted_docs) AS documents;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Рекомендации
|
||||||
|
|
||||||
|
### Немедленные действия:
|
||||||
|
|
||||||
|
1. **Обновить SQL в ноде `claimsave`**
|
||||||
|
- Заменить на исправленную версию из `FIXED_SQL_QUERY.md`
|
||||||
|
- Или использовать упрощенную версию выше
|
||||||
|
|
||||||
|
2. **Проверить параметры**
|
||||||
|
- Убедиться, что `queryReplacement` правильный: `={{ $json.payload_partial_json }}, {{ $json.claim_id }}`
|
||||||
|
- `payload_partial_json` должен быть JSON объектом
|
||||||
|
- `claim_id` должен быть строкой
|
||||||
|
|
||||||
|
3. **Протестировать**
|
||||||
|
- Запустить workflow с тестовыми данными
|
||||||
|
- Проверить, что `claim` не возвращает `null`
|
||||||
|
- Проверить, что документы сохраняются правильно
|
||||||
|
|
||||||
|
### Долгосрочные улучшения:
|
||||||
|
|
||||||
|
1. **Унифицировать все SQL запросы**
|
||||||
|
- Привести `claimsave_final` и `claimsave1` к единому формату
|
||||||
|
- Использовать строковый `claim_id` везде
|
||||||
|
|
||||||
|
2. **Добавить обработку ошибок**
|
||||||
|
- Проверять результат SQL запроса
|
||||||
|
- Логировать ошибки
|
||||||
|
- Возвращать понятные сообщения об ошибках
|
||||||
|
|
||||||
|
3. **Оптимизировать workflow**
|
||||||
|
- Упростить логику слияния payload
|
||||||
|
- Использовать транзакции для атомарности операций
|
||||||
|
|
||||||
|
## Готовый SQL для копирования
|
||||||
|
|
||||||
|
Полный исправленный SQL запрос находится в файле `FIXED_SQL_QUERY.md`.
|
||||||
|
|
||||||
|
Основные изменения:
|
||||||
|
- ✅ Использует `claim_final` CTE для правильного получения UUID
|
||||||
|
- ✅ `old` CTE всегда возвращает строку (даже если запись не найдена)
|
||||||
|
- ✅ Все подзапросы используют `LIMIT 1` для гарантии одной строки
|
||||||
|
- ✅ Правильное слияние `answers` и `documents_meta`
|
||||||
|
|
||||||
218
docs/WORKFLOW_OCR_ANALYSIS.md
Normal file
218
docs/WORKFLOW_OCR_ANALYSIS.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# Анализ workflow: шаг ?? ocr_jobs_clime (1IKe2PccqXLkD2KR)
|
||||||
|
|
||||||
|
## Общая информация
|
||||||
|
|
||||||
|
**ID:** `1IKe2PccqXLkD2KR`
|
||||||
|
**Название:** `шаг ?? ocr_jobs_clime`
|
||||||
|
**Статус:** Active
|
||||||
|
**Триггер:** Redis Pub/Sub на канале `clpr:ocr:jobs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Архитектура workflow
|
||||||
|
|
||||||
|
### 1. Триггер: Redis Pub/Sub
|
||||||
|
|
||||||
|
**Канал:** `clpr:ocr:jobs`
|
||||||
|
|
||||||
|
**Формат сообщения:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": {
|
||||||
|
"job_id": "...",
|
||||||
|
"claim_id": "uuid", // UUID из clpr_claims.id
|
||||||
|
"prefix": "clpr_",
|
||||||
|
"telegram_id": "...",
|
||||||
|
"session_token": "...",
|
||||||
|
"channel": "telegram|web_form",
|
||||||
|
"created_at": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Основной поток обработки
|
||||||
|
|
||||||
|
#### Шаг 1: Получение файлов из PostgreSQL
|
||||||
|
|
||||||
|
**Нода:** `Execute a SQL query`
|
||||||
|
|
||||||
|
**Запрос:**
|
||||||
|
```sql
|
||||||
|
-- Получает документы из clpr_claim_documents по claim_id (UUID)
|
||||||
|
SELECT
|
||||||
|
cd.id AS claim_document_id,
|
||||||
|
cd.claim_id::text AS claim_id,
|
||||||
|
cd.field_name,
|
||||||
|
cd.file_id,
|
||||||
|
cd.uploaded_at,
|
||||||
|
m.file_url,
|
||||||
|
m.file_name,
|
||||||
|
m.original_file_name,
|
||||||
|
-- ... описание из payload
|
||||||
|
FROM clpr_claim_documents cd
|
||||||
|
JOIN clpr_claims c ON c.id = cd.claim_id::uuid
|
||||||
|
-- ... метаданные из payload.documents_meta
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:** Использует `claim_id` как **UUID** (из `clpr_claims.id`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Шаг 2: Загрузка файла в S3
|
||||||
|
|
||||||
|
**Нода:** `Upload_OCR_File`
|
||||||
|
|
||||||
|
**Путь:** `temp/{telegram_id}/{file_name}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Шаг 3: OCR обработка
|
||||||
|
|
||||||
|
**Нода:** `HTTP Request2` → `http://147.45.146.17:8001/analyze-file`
|
||||||
|
|
||||||
|
**Параметры:**
|
||||||
|
- `file_url` - URL файла из S3
|
||||||
|
- `file_name` - имя файла
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Шаг 4: Обработка результатов OCR
|
||||||
|
|
||||||
|
**Нода:** `Edit Fields6`
|
||||||
|
|
||||||
|
**Извлекает:**
|
||||||
|
- `ocr_text` - текст OCR
|
||||||
|
- `vision_reason` - причина отправки в vision
|
||||||
|
- `nsfw` - флаг NSFW
|
||||||
|
- `page` - номер страницы
|
||||||
|
- `file_id` - ID документа из `claim_document_id`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Шаг 5: Сохранение результатов
|
||||||
|
|
||||||
|
**Нода:** `give_data1` (SQL запрос)
|
||||||
|
|
||||||
|
**Запрос:** Получает полные данные заявки:
|
||||||
|
- `claim` - данные заявки
|
||||||
|
- `documents` - документы
|
||||||
|
- `ocr_pages` - страницы OCR
|
||||||
|
- `vision_docs` - результаты vision
|
||||||
|
- `combined_docs` - объединенные документы
|
||||||
|
|
||||||
|
**Использует:** `claim_id` как **UUID** (из `clpr_claims.id`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Шаг 6: Публикация событий
|
||||||
|
|
||||||
|
**Нода:** `Redis Publish (SendMessage)2`
|
||||||
|
|
||||||
|
**Канал:** `events:SendMessage`
|
||||||
|
|
||||||
|
**Сообщение:** JSON с результатами обработки
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Интеграция с веб-формой
|
||||||
|
|
||||||
|
### Текущая ситуация:
|
||||||
|
|
||||||
|
1. **Веб-форма использует:**
|
||||||
|
- `claim_id` в формате `CLM-YYYY-MM-DD-XXXXXX` (строка)
|
||||||
|
- Сохраняет в `clpr_claims.payload->>'claim_id'`
|
||||||
|
|
||||||
|
2. **SQL запросы возвращают:**
|
||||||
|
- `claim.claim_id` = **UUID** в виде строки (из `clpr_claims.id`)
|
||||||
|
- `claim.claim_id_str` = строка `CLM-...` (из `payload->>'claim_id'`)
|
||||||
|
|
||||||
|
3. **Workflow ожидает:**
|
||||||
|
- `claim_id` как **UUID** (из `clpr_claims.id`)
|
||||||
|
- Использует `clpr_claims.id` для поиска
|
||||||
|
|
||||||
|
### Решение:
|
||||||
|
|
||||||
|
✅ **Ничего менять не нужно!**
|
||||||
|
|
||||||
|
При публикации в Redis канал `clpr:ocr:jobs` используем `claim.claim_id` (UUID), который возвращается из SQL запроса.
|
||||||
|
|
||||||
|
### Пример публикации в Redis:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// После claimsave или claimsave_final
|
||||||
|
const claim = $json.claim;
|
||||||
|
|
||||||
|
// Публикуем в Redis канал clpr:ocr:jobs
|
||||||
|
await redis.publish('clpr:ocr:jobs', JSON.stringify({
|
||||||
|
job_id: generateJobId(),
|
||||||
|
claim_id: claim.claim_id, // UUID из clpr_claims.id
|
||||||
|
prefix: 'clpr_',
|
||||||
|
channel: 'web_form',
|
||||||
|
session_token: claim.payload?.session_token,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Важно:** Используем `claim.claim_id` (UUID), а не `claim.claim_id_str` (CLM-...)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Рекомендации
|
||||||
|
|
||||||
|
### Для интеграции с веб-формой:
|
||||||
|
|
||||||
|
✅ **Ничего менять не нужно!**
|
||||||
|
|
||||||
|
1. **SQL запросы уже возвращают UUID:**
|
||||||
|
- `claim.claim_id` = UUID из `clpr_claims.id`
|
||||||
|
- `claim.claim_id_str` = строка CLM-... (для отображения)
|
||||||
|
|
||||||
|
2. **Публикация в Redis:**
|
||||||
|
- Используем `claim.claim_id` (UUID) при публикации в `clpr:ocr:jobs`
|
||||||
|
- Workflow будет работать без изменений
|
||||||
|
|
||||||
|
3. **Workflow:**
|
||||||
|
- Остается без изменений
|
||||||
|
- Принимает UUID и работает как обычно
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Текущие SQL запросы в workflow
|
||||||
|
|
||||||
|
### Запрос 1: Получение файлов (строка 485)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Использует: WHERE id = $1 (UUID)
|
||||||
|
FROM clpr_claims WHERE id = $1
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Работает как есть** - получаем UUID из `claim.claim_id`
|
||||||
|
|
||||||
|
### Запрос 2: Получение полных данных (строка 1020)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Использует: WHERE id = $1 (UUID)
|
||||||
|
FROM clpr_claims WHERE id = $1
|
||||||
|
```
|
||||||
|
|
||||||
|
✅ **Работает как есть** - получаем UUID из `claim.claim_id`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
✅ **Ничего менять не нужно!**
|
||||||
|
|
||||||
|
**Как это работает:**
|
||||||
|
1. Веб-форма сохраняет данные в PostgreSQL через `claimsave`
|
||||||
|
2. SQL запрос возвращает `claim.claim_id` (UUID из `clpr_claims.id`)
|
||||||
|
3. При публикации в Redis используем `claim.claim_id` (UUID)
|
||||||
|
4. Workflow получает UUID и работает без изменений
|
||||||
|
|
||||||
|
**Преимущества:**
|
||||||
|
- ✅ Workflow остается без изменений
|
||||||
|
- ✅ Нет необходимости в дополнительных преобразованиях
|
||||||
|
- ✅ Единый формат (UUID) для всех систем
|
||||||
|
|
||||||
61
docs/optimized_ai_agent_node.json
Normal file
61
docs/optimized_ai_agent_node.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"promptType": "define",
|
||||||
|
"text": "=Ты — аналитик по делам защиты прав потребителей. Создай динамический чек-лист (5-7 вопросов) + список документов для претензии/иска.\n\nВХОД:\n- USER_MESSAGE: \"{{ $json.chatInput }}\"\n- RAG_ANSWER: \"{{ $json.output }}\"\n- FORM_STEPS: {{ $json.questions_numbered_html }}\n\nПРАВИЛА:\n1. Извлекай ТОЛЬКО из USER_MESSAGE и RAG_ANSWER. Если нет — missing/needs_confirm.\n2. 5-7 вопросов (priority: 1=критично, 2=доп). Дополнительные помечай priority=2.\n3. Вопросы: name (snake_case), label (текст), control (input[type=\"text\"]|textarea|input[type=\"radio\"]), input_type (text|textarea|choice|file|confirm), required (bool), priority (1|2), ask_if ({field, op, value}|null), options ([{label,value}]|[]).\n4. Документы: id, name, required (bool), priority, accept (['pdf','jpg']), hints (подсказка).\n5. answers_prefill: [{name, value, confidence (0..1), needs_confirm (bool), source (\"user_message\"|\"rag_answer\"), evidence (≤120 chars)}] — только если явно есть в тексте.\n6. coverage_report.questions: [{name, status (\"covered\"|\"partial\"|\"missing\"), confidence, source?, value?}].\n7. Формат — строго JSON, без Markdown, без текста вне JSON.\n\nВЫХОД (JSON):\n{\n \"wizard_plan\": {\n \"version\": \"1.0\",\n \"case_type\": \"consumer\",\n \"questions\": [{\"order\": 1, \"name\": \"item\", \"label\": \"Что за товар/услуга?\", \"control\": \"input[type=\\\"text\\\"]\", \"input_type\": \"text\", \"required\": true, \"priority\": 1, \"ask_if\": null, \"options\": []}],\n \"documents\": [{\"id\": \"contract\", \"name\": \"Договор/заказ\", \"required\": true, \"priority\": 1, \"accept\": [\"pdf\", \"jpg\", \"png\"], \"hints\": \"Фото/скан договора\"}],\n \"user_text\": \"Краткое описание что потребуется и почему (2-3 предложения)\"\n },\n \"answers_prefill\": [{\"name\": \"item\", \"value\": \"...\", \"confidence\": 1, \"needs_confirm\": false, \"source\": \"user_message\", \"evidence\": \"...\"}],\n \"coverage_report\": {\n \"questions\": [{\"name\": \"item\", \"status\": \"covered\", \"confidence\": 1, \"source\": \"user_message\", \"value\": \"...\"}],\n \"docs_missing\": [\"contract\", \"payment\"]\n }\n}\n\nВыполни задачу и верни JSON.",
|
||||||
|
"options": {
|
||||||
|
"systemMessage": "Ты — эксперт по структурированию данных для юридических форм. Отвечай только валидным JSON без Markdown."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||||
|
"typeVersion": 2.2,
|
||||||
|
"position": [3504, 224],
|
||||||
|
"id": "ea8d4e57-28c2-4944-ac1d-442d4b17a89d",
|
||||||
|
"name": "AI Agent3 (Optimized)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"model": {
|
||||||
|
"__rl": true,
|
||||||
|
"value": "gpt-4o-mini",
|
||||||
|
"mode": "list",
|
||||||
|
"cachedResultName": "gpt-4o-mini"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"temperature": 0.3,
|
||||||
|
"maxTokens": 2000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||||
|
"typeVersion": 1.2,
|
||||||
|
"position": [3488, 448],
|
||||||
|
"id": "6471d211-5728-4e2f-91cc-bc2316ec151c",
|
||||||
|
"name": "OpenAI Chat Model3 (Optimized)",
|
||||||
|
"credentials": {
|
||||||
|
"openAiApi": {
|
||||||
|
"id": "5qYqegZhVPdCfxxB",
|
||||||
|
"name": "OpenAi account"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": {
|
||||||
|
"AI Agent3 (Optimized)": {
|
||||||
|
"main": [[]]
|
||||||
|
},
|
||||||
|
"OpenAI Chat Model3 (Optimized)": {
|
||||||
|
"ai_languageModel": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "AI Agent3 (Optimized)",
|
||||||
|
"type": "ai_languageModel",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
60
docs/optimized_wizard_prompt.txt
Normal file
60
docs/optimized_wizard_prompt.txt
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
Ты — аналитик по делам защиты прав потребителей. Создай динамический чек-лист (5-7 вопросов) + список документов для претензии/иска.
|
||||||
|
|
||||||
|
ВХОД:
|
||||||
|
- USER_MESSAGE: "{{ $json.chatInput }}"
|
||||||
|
- RAG_ANSWER: "{{ $json.output }}"
|
||||||
|
- FORM_STEPS: {{ $json.questions_numbered_html }}
|
||||||
|
|
||||||
|
ПРАВИЛА:
|
||||||
|
1. Извлекай ТОЛЬКО из USER_MESSAGE и RAG_ANSWER. Если нет — missing/needs_confirm.
|
||||||
|
2. 5-7 вопросов (priority: 1=критично, 2=доп). Дополнительные помечай priority=2.
|
||||||
|
3. Вопросы: name (snake_case), label (текст), control (input[type="text"]|textarea|input[type="radio"]), input_type (text|textarea|choice|file|confirm), required (bool), priority (1|2), ask_if ({field, op, value}|null), options ([{label,value}]|[]).
|
||||||
|
4. Документы: id, name, required (bool), priority, accept (['pdf','jpg']), hints (подсказка).
|
||||||
|
5. answers_prefill: [{name, value, confidence (0..1), needs_confirm (bool), source ("user_message"|"rag_answer"), evidence (≤120 chars)}] — только если явно есть в тексте.
|
||||||
|
6. coverage_report.questions: [{name, status ("covered"|"partial"|"missing"), confidence, source?, value?}].
|
||||||
|
7. Формат — строго JSON, без Markdown, без текста вне JSON.
|
||||||
|
|
||||||
|
ВЫХОД (JSON):
|
||||||
|
{
|
||||||
|
"wizard_plan": {
|
||||||
|
"version": "1.0",
|
||||||
|
"case_type": "consumer",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"order": 1,
|
||||||
|
"name": "item",
|
||||||
|
"label": "Что за товар/услуга?",
|
||||||
|
"control": "input[type=\"text\"]",
|
||||||
|
"input_type": "text",
|
||||||
|
"required": true,
|
||||||
|
"priority": 1,
|
||||||
|
"ask_if": null,
|
||||||
|
"options": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"id": "contract",
|
||||||
|
"name": "Договор/заказ",
|
||||||
|
"required": true,
|
||||||
|
"priority": 1,
|
||||||
|
"accept": ["pdf", "jpg", "png"],
|
||||||
|
"hints": "Фото/скан договора"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"user_text": "Краткое описание что потребуется и почему (2-3 предложения)"
|
||||||
|
},
|
||||||
|
"answers_prefill": [
|
||||||
|
{"name": "item", "value": "...", "confidence": 1, "needs_confirm": false, "source": "user_message", "evidence": "..."}
|
||||||
|
],
|
||||||
|
"coverage_report": {
|
||||||
|
"questions": [
|
||||||
|
{"name": "item", "status": "covered", "confidence": 1, "source": "user_message", "value": "..."}
|
||||||
|
],
|
||||||
|
"docs_missing": ["contract", "payment"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Выполни задачу и верни JSON.
|
||||||
|
|
||||||
|
|
||||||
113
docs/wizard_prompt_n8n.txt
Normal file
113
docs/wizard_prompt_n8n.txt
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
Ты — аналитик/структуратор по делам защиты прав потребителей. Твоя задача: на входе у тебя есть
|
||||||
|
|
||||||
|
1) USER_MESSAGE — письмо/описание ситуации от пользователя: "{{ $json.chatInput }}"
|
||||||
|
|
||||||
|
2) RAG_ANSWER — аналитическая справка/правовой ответ (вытянутая из базы): "{{ $json.output }}"
|
||||||
|
|
||||||
|
3) FORM_STEPS — текущий список шагов/поля формы (Google Sheets) в формате массива объектов:
|
||||||
|
1. Что за товар или услуга? (коротко) — name="item", input[type="text"]
|
||||||
|
2. Где и когда вы купили/заказали (магазин, сайт, дата)? — name="place_date", input[type="text"]
|
||||||
|
3. Сколько это стоило (примерно)? — name="price", input[type="text"]
|
||||||
|
4. В чём именно проблема? Опишите кратко. — name="problem", textarea
|
||||||
|
5. Какие шаги вы уже предпринимали для решения? — name="steps_taken", textarea
|
||||||
|
6. Есть ли у вас чеки/договор/акты? — name="docs_exist", input[type="radio"] [Да | Нет]
|
||||||
|
7. Есть ли у вас переписка (скриншоты, письма)? — name="correspondence_exist", input[type="radio"] [Да | Нет]
|
||||||
|
8. Что вы хотите получить? — name="expectation", input[type="radio"] [Возврат денег | Замена товара | услуги | Компенсация морального вреда | Другое]
|
||||||
|
9. Опишите ваше требование (если "Другое") — name="other_expectation", textarea
|
||||||
|
|
||||||
|
**ВАЖНО: В FORM_STEPS НЕТ вопросов про загрузку файлов!** Загрузка файлов происходит автоматически через блоки документов в секции `documents`. НЕ создавай вопросы с `input[type="file"]`, `input_type: "file"` или именами `upload_*`.
|
||||||
|
|
||||||
|
Задача: составить **динамический чек-лист** (5–7 ключевых уточняющих вопросов) + **список документов** для запроса у пользователя, чтобы:
|
||||||
|
|
||||||
|
- собрать доказательственную базу для претензии и/или иска;
|
||||||
|
- минимизировать долги и непонятности (приоритеты, условия загрузки файлов и т.д.);
|
||||||
|
- предварительно заполнить (prefill) поля формы, если информация уже есть в USER_MESSAGE или RAG_ANSWER.
|
||||||
|
|
||||||
|
**Правила работы (строго):**
|
||||||
|
|
||||||
|
1. Извлекай информацию ТОЛЬКО из USER_MESSAGE и RAG_ANSWER. Не придумывай фактов. Если чего-то нет — указывай это как missing/needs_confirm.
|
||||||
|
|
||||||
|
2. Выбирай 5–7 уточняющих вопросов (если нужно больше — добавь, но пометь дополнительные с priority=2). Приоритет 1 = критично для претензии; 2 = доп. полезно.
|
||||||
|
|
||||||
|
3. Вопросы должны быть написаны «юзер-дружелюбно» и соответствовать HTML controls (input[type="text"], textarea, input[type="radio"], input[type="checkbox"]). **НЕ используй input[type="file"]** — загрузка файлов происходит через блоки документов.
|
||||||
|
|
||||||
|
4. Для каждого вопроса вернуть: name (кодовое имя, латиницей или snake_case), label (текст вопроса), control (html-тип), input_type (text|textarea|choice|multi_choice), required (bool), priority (1|2), rationale (короткое объяснение — 1 предложение), ask_if (условие показа — nullable; формат: { "field":"name", "op":"==", "value":"Да" }), options (если choice — массив {label,value}).
|
||||||
|
|
||||||
|
5. Для документов вернуть: id, name, required(bool), priority, accept (['pdf','jpg'...]), hints (короткая подсказка).
|
||||||
|
|
||||||
|
6. Сформируй answers_prefill — массив объектов { name, value, confidence (0..1), needs_confirm(bool), source: "user_message"|"rag_answer", evidence (<=120 chars) } — если в USER_MESSAGE/RAG есть явный ответ; иначе пусто.
|
||||||
|
|
||||||
|
7. Сделай coverage_report.questions — для каждого вопроса: name, status: "covered"|"partial"|"missing", confidence (0..1), source (если есть), value (если есть).
|
||||||
|
|
||||||
|
8. Укажи risks (кратко — коды: DOCS_STATUS_UNKNOWN, EXPECTATION_UNSET, DATE_AMBIGUOUS и т.д.) и deadlines: включи USER_UPLOAD_TTL=48h и USER_APPROVAL_TTL=24h минимум.
|
||||||
|
|
||||||
|
9. Формат вывода — **строго JSON** ровно по описанной ниже внешней схеме. Никаких объяснений, текста вне JSON и никакого Markdown. Если не уверены в каком-то поле — ставьте null или пустой массив.
|
||||||
|
|
||||||
|
10. Тон — полезный, краткий; при предзаполнении ставьте realistic confidence (1 — явно в тексте; 0.7 — подразумевается; 0.4 — косвенно).
|
||||||
|
|
||||||
|
**КРИТИЧЕСКИ ВАЖНО: НЕ создавай вопросы про загрузку документов!**
|
||||||
|
- ❌ НЕ создавай вопросы типа "Пожалуйста, загрузите фото или сканы документов"
|
||||||
|
- ❌ НЕ создавай текстовые поля (text/textarea) для загрузки документов
|
||||||
|
- ❌ НЕ создавай поля типа `input[type="file"]` или `input_type: "file"` для загрузки документов
|
||||||
|
- ❌ НЕ создавай вопросы с именами `upload_*` или `upload_docs`, `upload_correspondence` и т.п.
|
||||||
|
- ✅ Вместо этого используй блоки документов (documents) в секции documents
|
||||||
|
- ✅ Если нужно узнать наличие документов, используй `multi_choice` с чекбоксами (`input[type="checkbox"]` и `input_type: "multi_choice"`)
|
||||||
|
- ✅ Загрузка файлов происходит автоматически через блоки документов, не нужно создавать для этого отдельные вопросы
|
||||||
|
|
||||||
|
**Дополнительно:** если вы добавляете новые поля в questions/documents — это допустимо, но не убирайте обязательные поля из схемы. Поле `name` должно совпадать с теми, что есть в FORM_STEPS, если вопрос — трансформация существующего шага; если новый — дайте уникальное name.
|
||||||
|
|
||||||
|
**Пример минимального ожидаемого выхода (фрагмент):**
|
||||||
|
|
||||||
|
{
|
||||||
|
"wizard_plan": {
|
||||||
|
"version":"1.0",
|
||||||
|
"case_type":"consumer",
|
||||||
|
"goals":[ "...", ... ],
|
||||||
|
"questions":[
|
||||||
|
{
|
||||||
|
"order": 1,
|
||||||
|
"name": "item",
|
||||||
|
"label": "Что за товар или услуга? (коротко)",
|
||||||
|
"control": "input[type=\"text\"]",
|
||||||
|
"input_type": "text",
|
||||||
|
"required": true,
|
||||||
|
"priority": 1,
|
||||||
|
"rationale": "...",
|
||||||
|
"ask_if": null,
|
||||||
|
"options": []
|
||||||
|
}
|
||||||
|
// ... вопросы (БЕЗ upload_* и input[type="file"]!)
|
||||||
|
],
|
||||||
|
"documents":[
|
||||||
|
{
|
||||||
|
"id":"contract",
|
||||||
|
"name":"Договор/заказ",
|
||||||
|
"required": true,
|
||||||
|
"priority": 1,
|
||||||
|
"accept":["pdf","jpg","png"],
|
||||||
|
"hints":"Фото/скан подписанного договора"
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
],
|
||||||
|
"ask_order":[ "item","place_date", ... ],
|
||||||
|
"user_text":"<пара предложений для вывода пользователю: что потребуется и почему>",
|
||||||
|
"notes":"короткая заметка",
|
||||||
|
"risks":[ "DOCS_STATUS_UNKNOWN", "EXPECTATION_UNSET" ],
|
||||||
|
"deadlines":[ {"type":"USER_UPLOAD_TTL","duration_hours":48}, {"type":"USER_APPROVAL_TTL","duration_hours":24} ]
|
||||||
|
},
|
||||||
|
"answers_prefill":[
|
||||||
|
{ "name":"item","value":"кровать-podium...","confidence":1,"needs_confirm":false,"source":"user_message","evidence":"9 августа оформили заказ ..."}
|
||||||
|
// ...
|
||||||
|
],
|
||||||
|
"coverage_report":{
|
||||||
|
"questions":[
|
||||||
|
{ "name":"item","status":"covered","confidence":1,"source":"user_message","value":"..." }
|
||||||
|
// ...
|
||||||
|
],
|
||||||
|
"docs_received": [], // при наличии
|
||||||
|
"docs_missing": ["contract","payment","correspondence"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Выполни задачу прямо сейчас и верни JSON согласно схеме.
|
||||||
|
|
||||||
406
docs/wizard_prompt_simple.txt
Normal file
406
docs/wizard_prompt_simple.txt
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
# Роль
|
||||||
|
|
||||||
|
Ты — юридический ассистент по защите прав потребителей. Ты помогаешь людям понять, какие необходимо собрать документы и сообщить дополнительные сведения, для решения их проблемы.
|
||||||
|
|
||||||
|
# Задача: Построение динамического визарда
|
||||||
|
|
||||||
|
Твоя задача — проанализировать описание проблемы пользователя и создать **динамический визард** — структурированный набор вопросов и списка документов, которые помогут собрать всю необходимую информацию для подготовки претензии или иска.
|
||||||
|
|
||||||
|
## Что такое визард?
|
||||||
|
|
||||||
|
Визард — это пошаговая форма, которая:
|
||||||
|
1. **Задаёт вопросы** пользователю для уточнения деталей дела
|
||||||
|
2. **Требует документы**, необходимые для доказательства фактов
|
||||||
|
3. **Автоматически заполняет** поля, если информация уже есть в описании
|
||||||
|
4. **Адаптируется** — показывает дополнительные вопросы в зависимости от ответов
|
||||||
|
|
||||||
|
## Входные данные
|
||||||
|
|
||||||
|
Ты получаешь только:
|
||||||
|
- **USER_DESCRIPTION**: Описание проблемы от пользователя (текст)
|
||||||
|
|
||||||
|
## Правила построения визарда
|
||||||
|
|
||||||
|
### 1. Анализ описания
|
||||||
|
|
||||||
|
Внимательно прочитай описание проблемы и определи:
|
||||||
|
- **Тип дела** (покупка товара, услуга, конфликт с продавцом, нарушение сроков и т.д.)
|
||||||
|
- **Что уже известно** из описания (товар/услуга, дата, место, сумма, проблема)
|
||||||
|
- **Что нужно уточнить** (детали, документы, шаги пользователя)
|
||||||
|
|
||||||
|
### 2. Вопросы (questions)
|
||||||
|
|
||||||
|
Создай **5-8 вопросов**, которые помогут собрать недостающую информацию.
|
||||||
|
|
||||||
|
**Обязательные вопросы для большинства дел (priority: 1):**
|
||||||
|
- **Что** — название товара/услуги (item) — **ВСЕГДА включай**
|
||||||
|
- **Кто** — продавец/исполнитель (seller) — **ВСЕГДА включай**
|
||||||
|
- **Где** — место покупки/заказа (purchase_place) — **ВСЕГДА включай**
|
||||||
|
- **Когда** — дата покупки/заказа (purchase_date) — **ВСЕГДА включай для товаров/услуг**
|
||||||
|
- **Сколько** — сумма покупки (purchase_amount) — **ВСЕГДА включай для товаров/услуг, критично для оценки ущерба**
|
||||||
|
- **Проблема** — описание дефекта/нарушения (problem_description) — **ВСЕГДА включай**
|
||||||
|
- **Действия** — что уже сделано (actions_taken) — **ВСЕГДА включай**
|
||||||
|
- **Гарантия** — есть ли гарантия и какой срок (warranty_info) — **ВСЕГДА включай для товаров, даже если не упомянуто в описании**
|
||||||
|
|
||||||
|
**Дополнительные вопросы (priority: 2):**
|
||||||
|
- Наличие документов (лучше сделать multi_choice с чекбоксами, а не текстовое поле) — **ИСПОЛЬЗУЙ `input[type="checkbox"]` и `input_type: "multi_choice"` для множественного выбора**
|
||||||
|
- Желаемый результат (возврат денег, замена, ремонт, компенсация) — вместо прямого вопроса про суд — используй `input[type="radio"]` для выбора одного варианта
|
||||||
|
|
||||||
|
**ВАЖНО: НЕ создавай вопросы про загрузку документов!**
|
||||||
|
- ❌ НЕ создавай вопросы типа "Пожалуйста, загрузите фото или сканы документов"
|
||||||
|
- ❌ НЕ создавай текстовые поля (text/textarea) для загрузки документов
|
||||||
|
- ❌ НЕ создавай поля типа `input[type="file"]` или `input_type: "file"` для загрузки документов
|
||||||
|
- ❌ НЕ создавай вопросы с именами `upload_*` или `upload_docs`, `upload_correspondence` и т.п.
|
||||||
|
- ✅ Вместо этого используй блоки документов (documents) в секции documents
|
||||||
|
- ✅ Если нужно узнать наличие документов, используй `multi_choice` с чекбоксами
|
||||||
|
- ✅ Загрузка файлов происходит автоматически через блоки документов, не нужно создавать для этого отдельные вопросы
|
||||||
|
|
||||||
|
**Приоритеты:**
|
||||||
|
- **priority: 1** — критически важные вопросы (что, где, когда, сколько, кто, проблема, действия, гарантия)
|
||||||
|
- **priority: 2** — дополнительные вопросы (детали, уточнения, факультативные)
|
||||||
|
|
||||||
|
**Типы вопросов:**
|
||||||
|
- `text` — короткий текст (название товара, место, сумма)
|
||||||
|
- `date` — дата (дата покупки, дата заказа) — **ИСПОЛЬЗУЙ `input[type="date"]` для дат, НЕ `text`**
|
||||||
|
- `textarea` — длинный текст (описание проблемы, детали)
|
||||||
|
- `choice` — выбор одного варианта (да/нет, тип требования) — используй `input[type="radio"]`
|
||||||
|
- `multi_choice` — выбор нескольких вариантов (наличие документов) — **ИСПОЛЬЗУЙ `input[type="checkbox"]` для множественного выбора**
|
||||||
|
|
||||||
|
**Условные вопросы:**
|
||||||
|
- Используй `ask_if` для вопросов, которые показываются только при определённых ответах
|
||||||
|
- **ВАЖНО:** Если в вопросе с вариантами есть опция "Другое", ВСЕГДА добавляй дополнительный вопрос с `ask_if`, который показывается только когда выбрано "Другое"
|
||||||
|
- Пример: если пользователь выбрал "Другое" в типе требования (`desired_outcome`), показать текстовое поле для уточнения (`desired_outcome_other`)
|
||||||
|
- Структура `ask_if`: `{"field": "desired_outcome", "op": "==", "value": "other"}`
|
||||||
|
|
||||||
|
**Структура вопроса:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"order": 1,
|
||||||
|
"name": "item",
|
||||||
|
"label": "Как называется товар или услуга?",
|
||||||
|
"control": "input[type=\"text\"]",
|
||||||
|
"input_type": "text",
|
||||||
|
"required": true,
|
||||||
|
"priority": 1,
|
||||||
|
"rationale": "Нужно точно определить предмет спора",
|
||||||
|
"ask_if": null,
|
||||||
|
"options": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Поля:**
|
||||||
|
- `order` — порядок отображения (1, 2, 3...)
|
||||||
|
- `name` — уникальное имя в snake_case (item, place_date, problem, etc.)
|
||||||
|
- `label` — текст вопроса для пользователя
|
||||||
|
- `control` — HTML-контрол ("input[type=\"text\"]", "input[type=\"date\"]", "textarea", "input[type=\"radio\"]", "input[type=\"checkbox\"]")
|
||||||
|
- `input_type` — тип ("text", "date", "textarea", "choice", "multi_choice") — **для дат ВСЕГДА используй "date", для множественного выбора документов ВСЕГДА используй "multi_choice"**
|
||||||
|
- `required` — обязательный ли вопрос (true/false)
|
||||||
|
- `priority` — приоритет (1 = критично, 2 = доп)
|
||||||
|
- `rationale` — почему этот вопрос важен (для логирования)
|
||||||
|
- `ask_if` — условие показа (null или {field, op, value})
|
||||||
|
- `options` — варианты для choice ([{label, value}])
|
||||||
|
|
||||||
|
### 3. Документы (documents)
|
||||||
|
|
||||||
|
Определи, какие документы нужны для доказательства фактов.
|
||||||
|
|
||||||
|
**Типы документов:**
|
||||||
|
- **Обязательные** (required: true) — договор, чеки, подтверждение оплаты
|
||||||
|
- **Дополнительные** (required: false) — переписка, скриншоты, фото
|
||||||
|
|
||||||
|
**Структура документа:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "contract",
|
||||||
|
"name": "Договор или подтверждение заказа",
|
||||||
|
"required": true,
|
||||||
|
"priority": 1,
|
||||||
|
"accept": ["pdf", "jpg", "png"],
|
||||||
|
"hints": "Фото или скан подписанного договора"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Поля:**
|
||||||
|
- `id` — уникальный идентификатор (contract, payment, correspondence, etc.)
|
||||||
|
- `name` — название документа для пользователя
|
||||||
|
- `required` — обязательный ли документ (true/false)
|
||||||
|
- `priority` — приоритет (1 = критично, 2 = доп)
|
||||||
|
- `accept` — допустимые форматы (["pdf", "jpg", "png"])
|
||||||
|
- `hints` — подсказка, что именно нужно загрузить
|
||||||
|
|
||||||
|
### 4. Автозаполнение (answers_prefill)
|
||||||
|
|
||||||
|
Если в описании пользователя уже есть ответы на вопросы, заполни их автоматически.
|
||||||
|
|
||||||
|
**Структура:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "item",
|
||||||
|
"value": "Онлайн-курс по программированию",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"needs_confirm": false,
|
||||||
|
"source": "user_description",
|
||||||
|
"evidence": "В описании упомянут 'онлайн-курс по программированию'"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Правила:**
|
||||||
|
- Извлекай ТОЛЬКО явно упомянутые факты
|
||||||
|
- `confidence` — уверенность (0.0-1.0)
|
||||||
|
- `needs_confirm` — нужна ли подтверждение от пользователя (false если уверен, true если сомневаешься)
|
||||||
|
- `source` — всегда "user_description"
|
||||||
|
- `evidence` — короткая цитата из описания (≤120 символов)
|
||||||
|
|
||||||
|
### 5. Отчёт о покрытии (coverage_report)
|
||||||
|
|
||||||
|
Покажи, какие вопросы уже покрыты описанием, а какие нужно задать.
|
||||||
|
|
||||||
|
**Структура:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"name": "item",
|
||||||
|
"status": "covered",
|
||||||
|
"confidence": 0.9,
|
||||||
|
"source": "user_description",
|
||||||
|
"value": "Онлайн-курс"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "place_date",
|
||||||
|
"status": "missing",
|
||||||
|
"confidence": 0,
|
||||||
|
"source": null,
|
||||||
|
"value": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"docs_received": [],
|
||||||
|
"docs_missing": ["contract", "payment"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Статусы:**
|
||||||
|
- `covered` — информация есть в описании
|
||||||
|
- `partial` — информация частично есть, нужно уточнить
|
||||||
|
- `missing` — информации нет, нужно спросить
|
||||||
|
|
||||||
|
## Формат вывода
|
||||||
|
|
||||||
|
Верни **строго JSON**, без Markdown, без дополнительного текста.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"wizard_plan": {
|
||||||
|
"version": "1.0",
|
||||||
|
"case_type": "consumer",
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"order": 1,
|
||||||
|
"name": "item",
|
||||||
|
"label": "Как называется товар или услуга?",
|
||||||
|
"control": "input[type=\"text\"]",
|
||||||
|
"input_type": "text",
|
||||||
|
"required": true,
|
||||||
|
"priority": 1,
|
||||||
|
"rationale": "Нужно точно определить предмет спора",
|
||||||
|
"ask_if": null,
|
||||||
|
"options": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 2,
|
||||||
|
"name": "purchase_date",
|
||||||
|
"label": "Когда был приобретён товар/заказана услуга?",
|
||||||
|
"control": "input[type=\"date\"]",
|
||||||
|
"input_type": "date",
|
||||||
|
"required": true,
|
||||||
|
"priority": 1,
|
||||||
|
"rationale": "Дата важна для определения гарантийного срока и сроков обращения",
|
||||||
|
"ask_if": null,
|
||||||
|
"options": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 3,
|
||||||
|
"name": "purchase_amount",
|
||||||
|
"label": "Сколько стоил товар/услуга?",
|
||||||
|
"control": "input[type=\"text\"]",
|
||||||
|
"input_type": "text",
|
||||||
|
"required": true,
|
||||||
|
"priority": 1,
|
||||||
|
"rationale": "Сумма нужна для оценки ущерба и размера требований",
|
||||||
|
"ask_if": null,
|
||||||
|
"options": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 4,
|
||||||
|
"name": "documents_available",
|
||||||
|
"label": "Какие документы у вас уже есть?",
|
||||||
|
"control": "input[type=\"checkbox\"]",
|
||||||
|
"input_type": "multi_choice",
|
||||||
|
"required": false,
|
||||||
|
"priority": 2,
|
||||||
|
"rationale": "Определить какие доказательства уже собраны",
|
||||||
|
"ask_if": null,
|
||||||
|
"options": [
|
||||||
|
{"label": "Чек", "value": "receipt"},
|
||||||
|
{"label": "Договор", "value": "contract"},
|
||||||
|
{"label": "Переписка", "value": "correspondence"},
|
||||||
|
{"label": "Фото/скриншоты", "value": "photos"},
|
||||||
|
{"label": "Акт диагностики/ремонта", "value": "diagnosis"},
|
||||||
|
{"label": "Досудебная претензия", "value": "pretrial_claim"},
|
||||||
|
{"label": "Другое", "value": "other"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 5,
|
||||||
|
"name": "desired_outcome",
|
||||||
|
"label": "Что вы хотите получить в результате?",
|
||||||
|
"control": "input[type=\"radio\"]",
|
||||||
|
"input_type": "choice",
|
||||||
|
"required": true,
|
||||||
|
"priority": 1,
|
||||||
|
"rationale": "Уточнение цели для корректного требования",
|
||||||
|
"ask_if": null,
|
||||||
|
"options": [
|
||||||
|
{"label": "Возврат денег", "value": "refund"},
|
||||||
|
{"label": "Замена товара/услуги", "value": "replacement"},
|
||||||
|
{"label": "Ремонт", "value": "repair"},
|
||||||
|
{"label": "Компенсация", "value": "compensation"},
|
||||||
|
{"label": "Другое", "value": "other"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"order": 6,
|
||||||
|
"name": "desired_outcome_other",
|
||||||
|
"label": "Опишите, пожалуйста, ваше требование",
|
||||||
|
"control": "input[type=\"text\"]",
|
||||||
|
"input_type": "text",
|
||||||
|
"required": true,
|
||||||
|
"priority": 1,
|
||||||
|
"rationale": "Уточнение нетипичного требования",
|
||||||
|
"ask_if": {"field": "desired_outcome", "op": "==", "value": "other"},
|
||||||
|
"options": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"documents": [
|
||||||
|
{
|
||||||
|
"id": "contract",
|
||||||
|
"name": "Договор или подтверждение заказа",
|
||||||
|
"required": true,
|
||||||
|
"priority": 1,
|
||||||
|
"accept": ["pdf", "jpg", "png"],
|
||||||
|
"hints": "Фото или скан подписанного договора"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"user_text": "Краткое описание (2-3 предложения) что потребуется собрать и почему"
|
||||||
|
},
|
||||||
|
"answers_prefill": [
|
||||||
|
{
|
||||||
|
"name": "item",
|
||||||
|
"value": "...",
|
||||||
|
"confidence": 1,
|
||||||
|
"needs_confirm": false,
|
||||||
|
"source": "user_description",
|
||||||
|
"evidence": "..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"coverage_report": {
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"name": "item",
|
||||||
|
"status": "covered",
|
||||||
|
"confidence": 1,
|
||||||
|
"source": "user_description",
|
||||||
|
"value": "..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"docs_received": [],
|
||||||
|
"docs_missing": ["contract", "payment"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Примеры типовых ситуаций
|
||||||
|
|
||||||
|
### Покупка товара с дефектом
|
||||||
|
**Вопросы (priority: 1) — ВСЕ эти вопросы ОБЯЗАТЕЛЬНЫ для товаров:**
|
||||||
|
1. Как называется товар? (item, text, required: true)
|
||||||
|
2. Кто продавец? (seller, text, required: true)
|
||||||
|
3. Где был приобретён товар? (purchase_place, text, required: true)
|
||||||
|
4. Когда был приобретён товар? (purchase_date, **date**, required: true) — **НЕ ПРОПУСКАЙ, используй input_type="date"**
|
||||||
|
5. Сколько стоил товар? (purchase_amount, text, required: true) — **НЕ ПРОПУСКАЙ**
|
||||||
|
6. Есть ли гарантия и какой срок? (warranty_info, text, required: true) — **НЕ ПРОПУСКАЙ для товаров**
|
||||||
|
7. Опишите проблему с товаром (problem_description, textarea, required: true)
|
||||||
|
8. Какие шаги уже предприняли? (actions_taken, textarea, required: false)
|
||||||
|
|
||||||
|
**Вопросы (priority: 2):**
|
||||||
|
9. Какие документы у вас есть? (documents_available, **multi_choice**) — **ИСПОЛЬЗУЙ `input[type="checkbox"]` и `input_type: "multi_choice"`** — варианты: чек, договор, переписка, фото дефекта, акт диагностики, досудебная претензия
|
||||||
|
10. Что вы хотите получить? (desired_outcome, choice) — используй `input[type="radio"]` для выбора одного варианта — варианты: возврат денег, замена товара, ремонт, компенсация, другое
|
||||||
|
11. **ОБЯЗАТЕЛЬНО:** Если в desired_outcome есть опция "Другое", добавь условный вопрос (desired_outcome_other, text) с `ask_if: {"field": "desired_outcome", "op": "==", "value": "other"}` для уточнения требования
|
||||||
|
|
||||||
|
**Документы:**
|
||||||
|
- Договор/чек (required: true)
|
||||||
|
- Фото дефекта (required: true)
|
||||||
|
- Переписка с продавцом (required: false)
|
||||||
|
- Акт диагностики/ремонта (required: false)
|
||||||
|
|
||||||
|
### Некачественная услуга
|
||||||
|
**Вопросы (priority: 1) — ВСЕ эти вопросы ОБЯЗАТЕЛЬНЫ для услуг:**
|
||||||
|
1. Какая услуга? (item, text, required: true)
|
||||||
|
2. Кто исполнитель? (seller, text, required: true)
|
||||||
|
3. Где заказали услугу? (purchase_place, text, required: true)
|
||||||
|
4. Когда заказали услугу? (purchase_date, **date**, required: true) — **НЕ ПРОПУСКАЙ, используй input_type="date"**
|
||||||
|
5. Сколько стоила услуга? (purchase_amount, text, required: true) — **НЕ ПРОПУСКАЙ**
|
||||||
|
6. В чём проблема? (problem_description, textarea, required: true)
|
||||||
|
7. Какие шаги уже предприняли? (actions_taken, textarea, required: false)
|
||||||
|
|
||||||
|
**Вопросы (priority: 2):**
|
||||||
|
8. Какие документы у вас есть? (documents_available, **multi_choice**) — **ИСПОЛЬЗУЙ `input[type="checkbox"]` и `input_type: "multi_choice"`**
|
||||||
|
9. Что вы хотите получить? (desired_outcome, choice) — используй `input[type="radio"]` для выбора одного варианта
|
||||||
|
10. **ОБЯЗАТЕЛЬНО:** Если в desired_outcome есть опция "Другое", добавь условный вопрос (desired_outcome_other, text) с `ask_if: {"field": "desired_outcome", "op": "==", "value": "other"}` для уточнения требования
|
||||||
|
|
||||||
|
**Документы:**
|
||||||
|
- Договор (required: true)
|
||||||
|
- Подтверждение оплаты (required: true)
|
||||||
|
- Переписка (required: false)
|
||||||
|
- Скриншоты/фото (required: false)
|
||||||
|
|
||||||
|
### Нарушение сроков
|
||||||
|
**Вопросы (priority: 1):**
|
||||||
|
1. Что заказали? (item, text)
|
||||||
|
2. Кто исполнитель? (seller, text)
|
||||||
|
3. Когда заказали? (purchase_date, text)
|
||||||
|
4. Когда должны были выполнить? (expected_date, text)
|
||||||
|
5. Когда фактически выполнили (или не выполнили)? (actual_date, text)
|
||||||
|
6. Сколько стоило? (purchase_amount, text)
|
||||||
|
7. Какие последствия? (problem_description, textarea)
|
||||||
|
8. Какие шаги уже предприняли? (actions_taken, textarea)
|
||||||
|
|
||||||
|
**Документы:**
|
||||||
|
- Договор с датами (required: true)
|
||||||
|
- Переписка (required: true)
|
||||||
|
- Подтверждение оплаты (required: true)
|
||||||
|
|
||||||
|
## Важные правила
|
||||||
|
|
||||||
|
1. **Будь конкретным** — вопросы должны быть понятными и конкретными
|
||||||
|
2. **Не дублируй** — если информация уже есть в описании, используй автозаполнение
|
||||||
|
3. **Адаптируйся** — учитывай тип ситуации (покупка товара ≠ конфликт в магазине)
|
||||||
|
4. **Обязательные поля** — для товаров/услуг ВСЕГДА включай в визард ВСЕ эти вопросы: дату покупки (purchase_date с input_type="date"), сумму (purchase_amount), гарантию (warranty_info для товаров). НЕ пропускай их, даже если они не упомянуты в описании — пользователь должен их заполнить.
|
||||||
|
5. **Тип поля для даты** — для даты покупки (purchase_date) ВСЕГДА используй `control: "input[type=\"date\"]"` и `input_type: "date"`, а НЕ текстовое поле.
|
||||||
|
6. **Вопрос про документы** — используй `multi_choice` с чекбоксами (`input[type="checkbox"]` и `input_type: "multi_choice"`), потому что пользователь может иметь несколько документов одновременно. НЕ используй `input[type="radio"]` для этого вопроса.
|
||||||
|
7. **Желаемый результат** — спрашивай "Что вы хотите получить?" с вариантами (возврат денег, замена, ремонт, компенсация, другое), а не "Хотите ли идти в суд?". **ВАЖНО:** Если есть опция "Другое", ВСЕГДА добавляй условный вопрос с `ask_if: {"field": "desired_outcome", "op": "==", "value": "other"}` для уточнения требования.
|
||||||
|
8. **Приоритеты** — сначала критичные (priority: 1), потом дополнительные (priority: 2)
|
||||||
|
9. **Документы обязательны** — для большинства дел нужны договор и подтверждение оплаты
|
||||||
|
10. **НЕ создавай вопросы про загрузку файлов** — НЕ создавай вопросы с `input_type: "file"`, `input[type="file"]`, именами `upload_*` или текстами "загрузите", "фото", "сканы". Загрузка файлов происходит автоматически через блоки документов в секции `documents`.
|
||||||
|
11. **Минимум вопросов** — 5-8 вопросов достаточно для большинства случаев, но не меньше обязательных полей
|
||||||
|
|
||||||
|
## Выполни задачу
|
||||||
|
|
||||||
|
Проанализируй описание проблемы пользователя и создай визард.
|
||||||
|
|
||||||
|
**ВХОД:**
|
||||||
|
- USER_DESCRIPTION: "{{ описание проблемы }}"
|
||||||
|
|
||||||
|
**ВЫХОД:**
|
||||||
|
Верни только JSON без Markdown разметки.
|
||||||
|
|
||||||
@@ -5,11 +5,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: #fafafa;
|
||||||
color: white;
|
color: #000000;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
border-bottom: 1px solid #d9d9d9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-header h1 {
|
.app-header h1 {
|
||||||
@@ -40,8 +41,8 @@
|
|||||||
|
|
||||||
.card h2 {
|
.card h2 {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
color: #333;
|
color: #000000;
|
||||||
border-bottom: 2px solid #667eea;
|
border-bottom: 2px solid #d9d9d9;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,8 +89,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card a {
|
.card a {
|
||||||
color: #667eea;
|
color: #000000;
|
||||||
text-decoration: none;
|
text-decoration: underline;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +102,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem;
|
padding: 3rem;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: #667eea;
|
color: #000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.success {
|
.success {
|
||||||
|
|||||||
@@ -49,3 +49,4 @@
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -50,12 +50,16 @@ export default function DebugPanel({ events, formData }: Props) {
|
|||||||
color: '#d4d4d4',
|
color: '#d4d4d4',
|
||||||
border: '1px solid #333'
|
border: '1px solid #333'
|
||||||
}}
|
}}
|
||||||
headStyle={{
|
styles={{
|
||||||
background: '#252526',
|
header: {
|
||||||
color: '#fff',
|
background: '#252526',
|
||||||
borderBottom: '1px solid #333'
|
color: '#fff',
|
||||||
|
borderBottom: '1px solid #333'
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
padding: 12
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
bodyStyle={{ padding: 12 }}
|
|
||||||
>
|
>
|
||||||
{/* Текущие данные формы */}
|
{/* Текущие данные формы */}
|
||||||
<div style={{ marginBottom: 16, padding: 12, background: '#2d2d30', borderRadius: 4 }}>
|
<div style={{ marginBottom: 16, padding: 12, background: '#2d2d30', borderRadius: 4 }}>
|
||||||
@@ -79,18 +83,17 @@ export default function DebugPanel({ events, formData }: Props) {
|
|||||||
<strong>Events Log:</strong>
|
<strong>Events Log:</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Timeline style={{ marginTop: 16 }}>
|
<Timeline
|
||||||
{events.length === 0 && (
|
style={{ marginTop: 16 }}
|
||||||
<Timeline.Item color="gray">
|
items={events.length === 0 ? [
|
||||||
<span style={{ color: '#888', fontSize: 12 }}>Нет событий...</span>
|
{
|
||||||
</Timeline.Item>
|
color: 'gray',
|
||||||
)}
|
children: <span style={{ color: '#888', fontSize: 12 }}>Нет событий...</span>
|
||||||
|
}
|
||||||
{events.map((event, index) => (
|
] : events.map((event, index) => ({
|
||||||
<Timeline.Item
|
key: index,
|
||||||
key={index}
|
dot: getIcon(event.status),
|
||||||
dot={getIcon(event.status)}
|
children: (
|
||||||
>
|
|
||||||
<div style={{ fontSize: 11, fontFamily: 'monospace' }}>
|
<div style={{ fontSize: 11, fontFamily: 'monospace' }}>
|
||||||
<div style={{ color: '#888', marginBottom: 4 }}>
|
<div style={{ color: '#888', marginBottom: 4 }}>
|
||||||
{event.timestamp}
|
{event.timestamp}
|
||||||
@@ -251,9 +254,9 @@ export default function DebugPanel({ events, formData }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Timeline.Item>
|
)
|
||||||
))}
|
}))}
|
||||||
</Timeline>
|
/>
|
||||||
|
|
||||||
{events.length > 0 && (
|
{events.length > 0 && (
|
||||||
<div style={{ marginTop: 16, padding: 8, background: '#2d2d30', borderRadius: 4, textAlign: 'center' }}>
|
<div style={{ marginTop: 16, padding: 8, background: '#2d2d30', borderRadius: 4, textAlign: 'center' }}>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PhoneOutlined, SafetyOutlined } from '@ant-design/icons';
|
|||||||
interface Props {
|
interface Props {
|
||||||
formData: any;
|
formData: any;
|
||||||
updateFormData: (data: any) => void;
|
updateFormData: (data: any) => void;
|
||||||
onNext: () => void;
|
onNext: (unified_id?: string) => void; // ✅ Может принимать unified_id
|
||||||
setIsPhoneVerified: (verified: boolean) => void;
|
setIsPhoneVerified: (verified: boolean) => void;
|
||||||
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
|
addDebugEvent?: (type: string, status: string, message: string, data?: any) => void;
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,8 @@ export default function Step1Phone({
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
phone,
|
phone,
|
||||||
session_id: formData.session_id // ✅ Передаём session_id
|
session_id: formData.session_id, // ✅ Передаём session_id
|
||||||
|
form_id: 'ticket_form' // ✅ Маркируем источник формы
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,6 +119,7 @@ export default function Step1Phone({
|
|||||||
phone,
|
phone,
|
||||||
contact_id: result.contact_id,
|
contact_id: result.contact_id,
|
||||||
claim_id: result.claim_id,
|
claim_id: result.claim_id,
|
||||||
|
unified_id: result.unified_id, // ← Добавляем в лог
|
||||||
is_new_contact: result.is_new_contact
|
is_new_contact: result.is_new_contact
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,13 +128,18 @@ export default function Step1Phone({
|
|||||||
// Сохраняем данные из CRM в форму
|
// Сохраняем данные из CRM в форму
|
||||||
updateFormData({
|
updateFormData({
|
||||||
phone,
|
phone,
|
||||||
|
smsCode: code,
|
||||||
contact_id: result.contact_id,
|
contact_id: result.contact_id,
|
||||||
|
unified_id: result.unified_id, // ✅ Unified ID из PostgreSQL (получаем от n8n)
|
||||||
claim_id: result.claim_id,
|
claim_id: result.claim_id,
|
||||||
is_new_contact: result.is_new_contact
|
is_new_contact: result.is_new_contact
|
||||||
});
|
});
|
||||||
|
|
||||||
message.success(result.is_new_contact ? 'Контакт создан!' : 'Контакт найден!');
|
message.success(result.is_new_contact ? 'Контакт создан!' : 'Контакт найден!');
|
||||||
onNext();
|
|
||||||
|
// ✅ Передаем unified_id напрямую в onNext для проверки черновиков
|
||||||
|
// Это нужно, потому что formData может еще не обновиться
|
||||||
|
onNext(result.unified_id);
|
||||||
} else {
|
} else {
|
||||||
addDebugEvent?.('crm', 'error', '❌ Ошибка создания контакта в CRM', crmResult);
|
addDebugEvent?.('crm', 'error', '❌ Ошибка создания контакта в CRM', crmResult);
|
||||||
message.error('Ошибка создания контакта в CRM');
|
message.error('Ошибка создания контакта в CRM');
|
||||||
@@ -173,13 +180,21 @@ export default function Step1Phone({
|
|||||||
{ pattern: /^\d{10}$/, message: 'Введите 10 цифр без кода страны' }
|
{ pattern: /^\d{10}$/, message: 'Введите 10 цифр без кода страны' }
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Input
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
prefix={<PhoneOutlined />}
|
<Input
|
||||||
addonBefore="+7"
|
readOnly
|
||||||
placeholder="9001234567"
|
value="+7"
|
||||||
maxLength={10}
|
size="large"
|
||||||
size="large"
|
style={{ width: '50px', textAlign: 'center', pointerEvents: 'none', background: '#f5f5f5' }}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
prefix={<PhoneOutlined />}
|
||||||
|
placeholder="9001234567"
|
||||||
|
maxLength={10}
|
||||||
|
size="large"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
|
|||||||
@@ -470,11 +470,11 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
|||||||
<div style={{
|
<div style={{
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
padding: 16,
|
padding: 16,
|
||||||
background: '#fff7e6',
|
background: '#fafafa',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
border: '1px solid #ffa940'
|
border: '1px solid #d9d9d9'
|
||||||
}}>
|
}}>
|
||||||
<p style={{ margin: 0, color: '#d46b08', fontWeight: 500 }}>
|
<p style={{ margin: 0, color: '#000000', fontWeight: 500 }}>
|
||||||
⚠️ Полис не найден в базе данных
|
⚠️ Полис не найден в базе данных
|
||||||
</p>
|
</p>
|
||||||
<p style={{ margin: '8px 0 0 0', fontSize: 13, color: '#666' }}>
|
<p style={{ margin: '8px 0 0 0', fontSize: 13, color: '#666' }}>
|
||||||
@@ -525,7 +525,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
|||||||
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
|
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
|
||||||
Поддерживаются: JPG, PNG, HEIC, WEBP, PDF (макс 15MB)
|
Поддерживаются: JPG, PNG, HEIC, WEBP, PDF (макс 15MB)
|
||||||
{fileList.length > 0 && (
|
{fileList.length > 0 && (
|
||||||
<span style={{ marginLeft: 8, color: '#52c41a' }}>
|
<span style={{ marginLeft: 8, color: '#595959' }}>
|
||||||
(автоконвертация в PDF)
|
(автоконвертация в PDF)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -570,7 +570,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!policyNotFound && (
|
{!policyNotFound && (
|
||||||
<div style={{ marginTop: 16, padding: 12, background: '#f0f9ff', borderRadius: 8 }}>
|
<div style={{ marginTop: 16, padding: 12, background: '#fafafa', borderRadius: 8 }}>
|
||||||
<p style={{ margin: 0, fontSize: 13, color: '#666' }}>
|
<p style={{ margin: 0, fontSize: 13, color: '#666' }}>
|
||||||
💡 Введите номер полиса. Кириллица автоматически заменяется на латиницу, тире вставляется автоматически
|
💡 Введите номер полиса. Кириллица автоматически заменяется на латиницу, тире вставляется автоматически
|
||||||
</p>
|
</p>
|
||||||
@@ -647,7 +647,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
|
|||||||
<div>
|
<div>
|
||||||
<p>{ocrModalContent.message || 'Документ не распознан'}</p>
|
<p>{ocrModalContent.message || 'Документ не распознан'}</p>
|
||||||
<p style={{ marginTop: 16 }}><strong>Полный ответ:</strong></p>
|
<p style={{ marginTop: 16 }}><strong>Полный ответ:</strong></p>
|
||||||
<pre style={{ background: '#fff3f3', padding: 12, borderRadius: 4, fontSize: 12, maxHeight: 400, overflow: 'auto' }}>
|
<pre style={{ background: '#fafafa', padding: 12, borderRadius: 4, fontSize: 12, maxHeight: 400, overflow: 'auto' }}>
|
||||||
{JSON.stringify(ocrModalContent.data, null, 2)}
|
{JSON.stringify(ocrModalContent.data, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
|
|
||||||
{/* Прогресс обработки документов */}
|
{/* Прогресс обработки документов */}
|
||||||
{eventType && currentDocuments.length > 0 && (
|
{eventType && currentDocuments.length > 0 && (
|
||||||
<Card style={{ marginBottom: 24, background: '#f0f9ff', borderColor: '#91d5ff' }}>
|
<Card style={{ marginBottom: 24, background: '#fafafa', borderColor: '#d9d9d9' }}>
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<strong>Прогресс обработки документов:</strong>
|
<strong>Прогресс обработки документов:</strong>
|
||||||
</div>
|
</div>
|
||||||
@@ -396,7 +396,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
{currentDocuments.map(doc =>
|
{currentDocuments.map(doc =>
|
||||||
processedDocuments[doc.field] ? (
|
processedDocuments[doc.field] ? (
|
||||||
<div key={doc.field} style={{ marginBottom: 8, color: '#52c41a' }}>
|
<div key={doc.field} style={{ marginBottom: 8, color: '#595959' }}>
|
||||||
<CheckCircleOutlined /> {doc.name} - ✅ Обработан
|
<CheckCircleOutlined /> {doc.name} - ✅ Обработан
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
@@ -411,14 +411,14 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
<Card
|
<Card
|
||||||
title={`📋 Шаг ${currentDocumentIndex + 1}/${currentDocuments.length}: ${currentDocConfig.name}`}
|
title={`📋 Шаг ${currentDocumentIndex + 1}/${currentDocuments.length}: ${currentDocConfig.name}`}
|
||||||
style={{ marginTop: 24 }}
|
style={{ marginTop: 24 }}
|
||||||
headStyle={{ background: currentDocConfig.required ? '#fff7e6' : '#f0f9ff', borderBottom: '2px solid #ffa940' }}
|
headStyle={{ background: currentDocConfig.required ? '#fafafa' : '#fafafa', borderBottom: '2px solid #d9d9d9' }}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 16, padding: 12, background: '#e6f7ff', borderRadius: 8 }}>
|
<div style={{ marginBottom: 16, padding: 12, background: '#fafafa', borderRadius: 8 }}>
|
||||||
<p style={{ margin: 0, fontSize: 13, color: '#0050b3' }}>
|
<p style={{ margin: 0, fontSize: 13, color: '#000000' }}>
|
||||||
💡 {currentDocConfig.description}
|
💡 {currentDocConfig.description}
|
||||||
</p>
|
</p>
|
||||||
{currentDocConfig.required && (
|
{currentDocConfig.required && (
|
||||||
<p style={{ margin: '8px 0 0 0', fontSize: 12, color: '#d46b08' }}>
|
<p style={{ margin: '8px 0 0 0', fontSize: 12, color: '#595959' }}>
|
||||||
⚠️ Этот документ обязательный
|
⚠️ Этот документ обязательный
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -475,11 +475,11 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
{/* Если все документы обработаны или текущий индекс вышел за пределы */}
|
{/* Если все документы обработаны или текущий индекс вышел за пределы */}
|
||||||
{eventType && currentDocumentIndex >= currentDocuments.length && (
|
{eventType && currentDocumentIndex >= currentDocuments.length && (
|
||||||
<Card
|
<Card
|
||||||
style={{ marginTop: 24, background: '#f6ffed', borderColor: '#b7eb8f' }}
|
style={{ marginTop: 24, background: '#fafafa', borderColor: '#d9d9d9' }}
|
||||||
>
|
>
|
||||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||||
<CheckCircleOutlined style={{ fontSize: 48, color: '#52c41a', marginBottom: 16 }} />
|
<CheckCircleOutlined style={{ fontSize: 48, color: '#595959', marginBottom: 16 }} />
|
||||||
<h3 style={{ color: '#52c41a' }}>✅ Все документы обработаны!</h3>
|
<h3 style={{ color: '#000000' }}>✅ Все документы обработаны!</h3>
|
||||||
<p style={{ color: '#666' }}>
|
<p style={{ color: '#666' }}>
|
||||||
Обработано обязательных документов: {processedRequired}/{totalRequired}
|
Обработано обязательных документов: {processedRequired}/{totalRequired}
|
||||||
</p>
|
</p>
|
||||||
@@ -533,12 +533,12 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
</p>
|
</p>
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: 16,
|
padding: 16,
|
||||||
background: '#f6ffed',
|
background: '#fafafa',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
border: '1px solid #b7eb8f',
|
border: '1px solid #d9d9d9',
|
||||||
marginBottom: 16
|
marginBottom: 16
|
||||||
}}>
|
}}>
|
||||||
<p style={{ margin: '0 0 8px 0', color: '#52c41a', fontWeight: 500 }}>
|
<p style={{ margin: '0 0 8px 0', color: '#000000', fontWeight: 500 }}>
|
||||||
✅ Документ успешно распознан
|
✅ Документ успешно распознан
|
||||||
</p>
|
</p>
|
||||||
<p style={{ margin: 0, fontSize: 13, color: '#666' }}>
|
<p style={{ margin: 0, fontSize: 13, color: '#666' }}>
|
||||||
@@ -562,7 +562,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
|
|||||||
<p>{ocrModalContent.message || 'Документ не распознан'}</p>
|
<p>{ocrModalContent.message || 'Документ не распознан'}</p>
|
||||||
<p style={{ marginTop: 16 }}><strong>Детали:</strong></p>
|
<p style={{ marginTop: 16 }}><strong>Детали:</strong></p>
|
||||||
<pre style={{
|
<pre style={{
|
||||||
background: '#fff3f3',
|
background: '#fafafa',
|
||||||
padding: 12,
|
padding: 12,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ const Step2EventType: React.FC<Props> = ({ formData, updateFormData, onNext, onP
|
|||||||
<Card>
|
<Card>
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
<h2 style={{ fontSize: 24, fontWeight: 600, marginBottom: 8 }}>
|
<h2 style={{ fontSize: 24, fontWeight: 600, marginBottom: 8 }}>
|
||||||
<ThunderboltOutlined style={{ marginRight: 8, color: '#1890ff' }} />
|
<ThunderboltOutlined style={{ marginRight: 8, color: '#595959' }} />
|
||||||
Выберите тип страхового случая
|
Выберите тип страхового случая
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ color: '#666', margin: 0 }}>
|
<p style={{ color: '#666', margin: 0 }}>
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ export default function Step3Payment({
|
|||||||
initialValues={formData}
|
initialValues={formData}
|
||||||
style={{ marginTop: 24 }}
|
style={{ marginTop: 24 }}
|
||||||
>
|
>
|
||||||
|
{/* Скрытые технические поля */}
|
||||||
|
<Form.Item name="clientIp" hidden>
|
||||||
|
<Input type="hidden" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="smsCode" hidden>
|
||||||
|
<Input type="hidden" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
{/* Кнопка Назад вверху */}
|
{/* Кнопка Назад вверху */}
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<Button onClick={onPrev} size="large">
|
<Button onClick={onPrev} size="large">
|
||||||
@@ -242,9 +250,9 @@ export default function Step3Payment({
|
|||||||
style={{
|
style={{
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
background: '#fffbe6',
|
background: '#fafafa',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
border: '1px dashed #faad14',
|
border: '1px dashed #d9d9d9',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 12,
|
gap: 12,
|
||||||
@@ -271,9 +279,9 @@ export default function Step3Payment({
|
|||||||
{isPhoneVerified && (
|
{isPhoneVerified && (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: 12,
|
padding: 12,
|
||||||
background: '#f0f9ff',
|
background: '#fafafa',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
border: '1px solid #91d5ff'
|
border: '1px solid #d9d9d9'
|
||||||
}}>
|
}}>
|
||||||
✅ Телефон подтвержден
|
✅ Телефон подтвержден
|
||||||
</div>
|
</div>
|
||||||
@@ -294,11 +302,11 @@ export default function Step3Payment({
|
|||||||
>
|
>
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '12px',
|
padding: '12px',
|
||||||
background: '#f0f9ff',
|
background: '#fafafa',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
border: '1px solid #91d5ff'
|
border: '1px solid #d9d9d9'
|
||||||
}}>
|
}}>
|
||||||
<QrcodeOutlined style={{ fontSize: 20, color: '#1890ff', marginRight: 8 }} />
|
<QrcodeOutlined style={{ fontSize: 20, color: '#595959', marginRight: 8 }} />
|
||||||
<strong>СБП (Система быстрых платежей)</strong>
|
<strong>СБП (Система быстрых платежей)</strong>
|
||||||
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: 13 }}>
|
<p style={{ margin: '8px 0 0 0', color: '#666', fontSize: 13 }}>
|
||||||
Выплата поступит на ваш счет в течение нескольких минут
|
Выплата поступит на ваш счет в течение нескольких минут
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export default function StepDescription({
|
|||||||
marginTop: 24,
|
marginTop: 24,
|
||||||
padding: 24,
|
padding: 24,
|
||||||
background: '#f6f8fa',
|
background: '#f6f8fa',
|
||||||
borderRadius: 12,
|
borderRadius: 8,
|
||||||
border: '1px solid #e0e6ed',
|
border: '1px solid #e0e6ed',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -176,8 +176,8 @@ export default function StepDescription({
|
|||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
background: '#eef2ff',
|
background: '#fafafa',
|
||||||
border: '1px dashed #c7d2fe',
|
border: '1px dashed #d9d9d9',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -222,22 +222,22 @@ const StepDocumentUpload: React.FC<Props> = ({
|
|||||||
<Progress
|
<Progress
|
||||||
percent={Math.round(((currentDocNumber - 1) / totalDocs) * 100)}
|
percent={Math.round(((currentDocNumber - 1) / totalDocs) * 100)}
|
||||||
showInfo={false}
|
showInfo={false}
|
||||||
strokeColor="#1890ff"
|
strokeColor="#595959"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Заголовок */}
|
{/* Заголовок */}
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div style={{ marginBottom: 24 }}>
|
||||||
<h2 style={{ fontSize: 24, fontWeight: 600, marginBottom: 8 }}>
|
<h2 style={{ fontSize: 24, fontWeight: 600, marginBottom: 8 }}>
|
||||||
<FileTextOutlined style={{ marginRight: 8, color: '#1890ff' }} />
|
<FileTextOutlined style={{ marginRight: 8, color: '#595959' }} />
|
||||||
{documentConfig.name}
|
{documentConfig.name}
|
||||||
{documentConfig.required && <span style={{ color: '#ff4d4f', marginLeft: 8 }}>*</span>}
|
{documentConfig.required && <span style={{ color: '#000000', marginLeft: 8 }}>*</span>}
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ color: '#666', margin: 0 }}>
|
<p style={{ color: '#666', margin: 0 }}>
|
||||||
{documentConfig.description}
|
{documentConfig.description}
|
||||||
</p>
|
</p>
|
||||||
{!documentConfig.required && (
|
{!documentConfig.required && (
|
||||||
<p style={{ color: '#faad14', fontSize: 12, marginTop: 4 }}>
|
<p style={{ color: '#595959', fontSize: 12, marginTop: 4 }}>
|
||||||
⚠️ Этот документ необязателен, можно пропустить
|
⚠️ Этот документ необязателен, можно пропустить
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
255
frontend/src/components/form/StepDraftSelection.tsx
Normal file
255
frontend/src/components/form/StepDraftSelection.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Card, List, Typography, Space, Empty, Popconfirm, message, Spin, Tag } from 'antd';
|
||||||
|
import { FileTextOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
// Форматирование даты без date-fns (если библиотека не установлена)
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const day = date.getDate();
|
||||||
|
const month = date.toLocaleDateString('ru-RU', { month: 'long' });
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${day} ${month} ${year}, ${hours}:${minutes}`;
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
id: string;
|
||||||
|
claim_id: string;
|
||||||
|
session_token: string;
|
||||||
|
status_code: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
problem_description?: string;
|
||||||
|
wizard_plan: boolean;
|
||||||
|
wizard_answers: boolean;
|
||||||
|
has_documents: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
phone: string;
|
||||||
|
session_id?: string;
|
||||||
|
onSelectDraft: (claimId: string) => void;
|
||||||
|
onNewClaim: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StepDraftSelection({
|
||||||
|
phone,
|
||||||
|
session_id,
|
||||||
|
onSelectDraft,
|
||||||
|
onNewClaim,
|
||||||
|
}: Props) {
|
||||||
|
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadDrafts = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (session_id) {
|
||||||
|
params.append('session_id', session_id);
|
||||||
|
} else if (phone) {
|
||||||
|
params.append('phone', phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/v1/claims/drafts/list?${params.toString()}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Не удалось загрузить черновики');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setDrafts(data.drafts || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки черновиков:', error);
|
||||||
|
message.error('Не удалось загрузить список черновиков');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDrafts();
|
||||||
|
}, [phone, session_id]);
|
||||||
|
|
||||||
|
const handleDelete = async (claimId: string) => {
|
||||||
|
try {
|
||||||
|
setDeletingId(claimId);
|
||||||
|
const response = await fetch(`/api/v1/claims/drafts/${claimId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Не удалось удалить черновик');
|
||||||
|
}
|
||||||
|
|
||||||
|
message.success('Черновик удален');
|
||||||
|
await loadDrafts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка удаления черновика:', error);
|
||||||
|
message.error('Не удалось удалить черновик');
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const getProgressInfo = (draft: Draft) => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (draft.problem_description) parts.push('Описание');
|
||||||
|
if (draft.wizard_plan) parts.push('План вопросов');
|
||||||
|
if (draft.wizard_answers) parts.push('Ответы');
|
||||||
|
if (draft.has_documents) parts.push('Документы');
|
||||||
|
return parts.length > 0 ? parts.join(', ') : 'Начато';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 800, margin: '0 auto', padding: '24px 0' }}>
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid #d9d9d9',
|
||||||
|
background: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
|
<div>
|
||||||
|
<Title level={3} style={{ marginBottom: 8 }}>
|
||||||
|
Продолжить заполнение или создать новую заявку?
|
||||||
|
</Title>
|
||||||
|
<Paragraph type="secondary">
|
||||||
|
У вас есть незавершенные черновики. Вы можете продолжить заполнение или создать новую заявку.
|
||||||
|
</Paragraph>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : drafts.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
description="У вас нет незавершенных черновиков"
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={onNewClaim} size="large">
|
||||||
|
Создать новую заявку
|
||||||
|
</Button>
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<List
|
||||||
|
dataSource={drafts}
|
||||||
|
renderItem={(draft) => (
|
||||||
|
<List.Item
|
||||||
|
style={{
|
||||||
|
padding: '16px',
|
||||||
|
border: '1px solid #d9d9d9',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
background: '#fff',
|
||||||
|
}}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
key="continue"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => onSelectDraft(draft.claim_id!)}
|
||||||
|
icon={<FileTextOutlined />}
|
||||||
|
>
|
||||||
|
Продолжить
|
||||||
|
</Button>,
|
||||||
|
<Popconfirm
|
||||||
|
key="delete"
|
||||||
|
title="Удалить черновик?"
|
||||||
|
description="Это действие нельзя отменить"
|
||||||
|
onConfirm={() => handleDelete(draft.claim_id!)}
|
||||||
|
okText="Да, удалить"
|
||||||
|
cancelText="Отмена"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
loading={deletingId === draft.claim_id}
|
||||||
|
disabled={deletingId === draft.claim_id}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={<FileTextOutlined style={{ fontSize: 24, color: '#595959' }} />}
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<Text strong>Черновик {draft.claim_id}</Text>
|
||||||
|
<Tag color="default">Черновик</Tag>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
Обновлен: {formatDate(draft.updated_at)}
|
||||||
|
</Text>
|
||||||
|
{draft.problem_description && (
|
||||||
|
<Text
|
||||||
|
ellipsis={{ tooltip: draft.problem_description }}
|
||||||
|
style={{ fontSize: 13 }}
|
||||||
|
>
|
||||||
|
{draft.problem_description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Space size="small">
|
||||||
|
<Tag color={draft.wizard_plan ? 'green' : 'default'}>
|
||||||
|
{draft.wizard_plan ? '✓ План' : 'План'}
|
||||||
|
</Tag>
|
||||||
|
<Tag color={draft.wizard_answers ? 'green' : 'default'}>
|
||||||
|
{draft.wizard_answers ? '✓ Ответы' : 'Ответы'}
|
||||||
|
</Tag>
|
||||||
|
<Tag color={draft.has_documents ? 'green' : 'default'}>
|
||||||
|
{draft.has_documents ? '✓ Документы' : 'Документы'}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
Прогресс: {getProgressInfo(draft)}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={onNewClaim}
|
||||||
|
size="large"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
Создать новую заявку
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={loadDrafts}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
Обновить список
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Button, Card, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
|
import { Button, Card, Checkbox, Form, Input, Radio, Result, Select, Skeleton, Space, Tag, Typography, Upload, message, Progress } from 'antd';
|
||||||
import { LoadingOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
import { LoadingOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||||
import AiWorkingIllustration from '../../assets/ai-working.svg';
|
import AiWorkingIllustration from '../../assets/ai-working.svg';
|
||||||
import type { UploadFile } from 'antd/es/upload/interface';
|
import type { UploadFile } from 'antd/es/upload/interface';
|
||||||
@@ -57,11 +57,16 @@ const evaluateCondition = (condition: WizardQuestion['ask_if'], values: Record<s
|
|||||||
if (!condition) return true;
|
if (!condition) return true;
|
||||||
const left = values?.[condition.field];
|
const left = values?.[condition.field];
|
||||||
const right = condition.value;
|
const right = condition.value;
|
||||||
|
|
||||||
|
// Приводим к строкам для более надёжного сравнения (Radio.Group может возвращать строки)
|
||||||
|
const leftStr = left != null ? String(left) : null;
|
||||||
|
const rightStr = right != null ? String(right) : null;
|
||||||
|
|
||||||
switch (condition.op) {
|
switch (condition.op) {
|
||||||
case '==':
|
case '==':
|
||||||
return left === right;
|
return leftStr === rightStr;
|
||||||
case '!=':
|
case '!=':
|
||||||
return left !== right;
|
return leftStr !== rightStr;
|
||||||
case '>':
|
case '>':
|
||||||
return left > right;
|
return left > right;
|
||||||
case '<':
|
case '<':
|
||||||
@@ -109,6 +114,7 @@ export default function StepWizardPlan({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const debugLoggerRef = useRef<typeof addDebugEvent>(addDebugEvent);
|
const debugLoggerRef = useRef<typeof addDebugEvent>(addDebugEvent);
|
||||||
const [isWaiting, setIsWaiting] = useState(!formData.wizardPlan);
|
const [isWaiting, setIsWaiting] = useState(!formData.wizardPlan);
|
||||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||||
@@ -122,6 +128,10 @@ export default function StepWizardPlan({
|
|||||||
const [customFileBlocks, setCustomFileBlocks] = useState<FileBlock[]>(
|
const [customFileBlocks, setCustomFileBlocks] = useState<FileBlock[]>(
|
||||||
formData.wizardUploads?.custom || []
|
formData.wizardUploads?.custom || []
|
||||||
);
|
);
|
||||||
|
const [skippedDocuments, setSkippedDocuments] = useState<Set<string>>(
|
||||||
|
new Set(formData.wizardSkippedDocuments || [])
|
||||||
|
);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [progressState, setProgressState] = useState<{ done: number; total: number }>({
|
const [progressState, setProgressState] = useState<{ done: number; total: number }>({
|
||||||
done: 0,
|
done: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -131,26 +141,6 @@ export default function StepWizardPlan({
|
|||||||
if (!progressState.total) return 0;
|
if (!progressState.total) return 0;
|
||||||
return Math.round((progressState.done / progressState.total) * 100);
|
return Math.round((progressState.done / progressState.total) * 100);
|
||||||
}, [progressState]);
|
}, [progressState]);
|
||||||
const persistUploads = useCallback(
|
|
||||||
(nextDocuments: Record<string, FileBlock[]>, nextCustom: FileBlock[]) => {
|
|
||||||
updateFormData({
|
|
||||||
wizardUploads: {
|
|
||||||
documents: nextDocuments,
|
|
||||||
custom: nextCustom,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[updateFormData]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (formData.wizardUploads?.documents) {
|
|
||||||
setQuestionFileBlocks(formData.wizardUploads.documents);
|
|
||||||
}
|
|
||||||
if (formData.wizardUploads?.custom) {
|
|
||||||
setCustomFileBlocks(formData.wizardUploads.custom);
|
|
||||||
}
|
|
||||||
}, [formData.wizardUploads]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
debugLoggerRef.current = addDebugEvent;
|
debugLoggerRef.current = addDebugEvent;
|
||||||
@@ -196,32 +186,35 @@ export default function StepWizardPlan({
|
|||||||
const currentBlocks = nextDocs[docId] || [];
|
const currentBlocks = nextDocs[docId] || [];
|
||||||
const updated = updater(currentBlocks);
|
const updated = updater(currentBlocks);
|
||||||
nextDocs[docId] = updated;
|
nextDocs[docId] = updated;
|
||||||
persistUploads(nextDocs, customFileBlocks);
|
|
||||||
return nextDocs;
|
return nextDocs;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[customFileBlocks, persistUploads]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCustomBlocksChange = useCallback(
|
const handleCustomBlocksChange = useCallback(
|
||||||
(updater: (blocks: FileBlock[]) => FileBlock[]) => {
|
(updater: (blocks: FileBlock[]) => FileBlock[]) => {
|
||||||
setCustomFileBlocks((prev) => {
|
setCustomFileBlocks((prev) => {
|
||||||
const updated = updater(prev);
|
const updated = updater(prev);
|
||||||
persistUploads(questionFileBlocks, updated);
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[persistUploads, questionFileBlocks]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const addDocumentBlock = (docId: string, docLabel?: string) => {
|
const addDocumentBlock = (docId: string, docLabel?: string, docList?: WizardDocument[]) => {
|
||||||
|
// Для предопределённых документов используем их ID как категорию
|
||||||
|
const category = docList && docList.length === 1 && docList[0].id && !docList[0].id.includes('_exist')
|
||||||
|
? docList[0].id
|
||||||
|
: docId;
|
||||||
|
|
||||||
handleDocumentBlocksChange(docId, (blocks) => [
|
handleDocumentBlocksChange(docId, (blocks) => [
|
||||||
...blocks,
|
...blocks,
|
||||||
{
|
{
|
||||||
id: generateBlockId(docId),
|
id: generateBlockId(docId),
|
||||||
fieldName: docId,
|
fieldName: docId,
|
||||||
description: '',
|
description: '',
|
||||||
category: docId,
|
category: category,
|
||||||
docLabel: docLabel,
|
docLabel: docLabel,
|
||||||
files: [],
|
files: [],
|
||||||
},
|
},
|
||||||
@@ -304,6 +297,47 @@ export default function StepWizardPlan({
|
|||||||
setProgressState({ done, total });
|
setProgressState({ done, total });
|
||||||
}, [formValues, questions]);
|
}, [formValues, questions]);
|
||||||
|
|
||||||
|
// Автоматически создаём блоки для обязательных документов при ответе "Да"
|
||||||
|
useEffect(() => {
|
||||||
|
if (!plan || !formValues) return;
|
||||||
|
|
||||||
|
questions.forEach((question) => {
|
||||||
|
const visible = evaluateCondition(question.ask_if, formValues);
|
||||||
|
if (!visible) return;
|
||||||
|
|
||||||
|
const questionValue = formValues?.[question.name];
|
||||||
|
if (!isAffirmative(questionValue)) return;
|
||||||
|
|
||||||
|
const questionDocs = documentGroups[question.name] || [];
|
||||||
|
questionDocs.forEach((doc) => {
|
||||||
|
if (!doc.required) return;
|
||||||
|
|
||||||
|
const docKey = doc.id || doc.name || `doc_${question.name}`;
|
||||||
|
|
||||||
|
// Не создаём блок, если документ пропущен
|
||||||
|
if (skippedDocuments.has(docKey)) return;
|
||||||
|
|
||||||
|
const existingBlocks = questionFileBlocks[docKey] || [];
|
||||||
|
|
||||||
|
// Если блока ещё нет, создаём его автоматически
|
||||||
|
if (existingBlocks.length === 0) {
|
||||||
|
const category = doc.id && !doc.id.includes('_exist') ? doc.id : docKey;
|
||||||
|
handleDocumentBlocksChange(docKey, (blocks) => [
|
||||||
|
...blocks,
|
||||||
|
{
|
||||||
|
id: generateBlockId(docKey),
|
||||||
|
fieldName: docKey,
|
||||||
|
description: '',
|
||||||
|
category: category,
|
||||||
|
docLabel: doc.name,
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [formValues, plan, questions, documentGroups, questionFileBlocks, handleDocumentBlocksChange, skippedDocuments]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWaiting || !formData.claim_id || plan) {
|
if (!isWaiting || !formData.claim_id || plan) {
|
||||||
return;
|
return;
|
||||||
@@ -313,6 +347,16 @@ export default function StepWizardPlan({
|
|||||||
const source = new EventSource(`/events/${claimId}`);
|
const source = new EventSource(`/events/${claimId}`);
|
||||||
eventSourceRef.current = source;
|
eventSourceRef.current = source;
|
||||||
debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { claim_id: claimId });
|
debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { claim_id: claimId });
|
||||||
|
|
||||||
|
// Таймаут: если план не пришёл за 2 минуты (RAG может работать долго), показываем ошибку
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setConnectionError('План вопросов не получен. Проверьте, что n8n обработал описание проблемы.');
|
||||||
|
debugLoggerRef.current?.('wizard', 'error', '⏱️ Таймаут ожидания плана вопросов (2 минуты)', { claim_id: claimId });
|
||||||
|
if (eventSourceRef.current) {
|
||||||
|
eventSourceRef.current.close();
|
||||||
|
eventSourceRef.current = null;
|
||||||
|
}
|
||||||
|
}, 120000); // 2 минуты для RAG обработки
|
||||||
|
|
||||||
source.onopen = () => {
|
source.onopen = () => {
|
||||||
setConnectionError(null);
|
setConnectionError(null);
|
||||||
@@ -357,6 +401,15 @@ export default function StepWizardPlan({
|
|||||||
payload?.data?.event_type ||
|
payload?.data?.event_type ||
|
||||||
payload?.redis_value?.event_type;
|
payload?.redis_value?.event_type;
|
||||||
|
|
||||||
|
// Логируем все события для отладки
|
||||||
|
debugLoggerRef.current?.('wizard', 'info', '📨 Получено SSE событие', {
|
||||||
|
claim_id: claimId,
|
||||||
|
event_type: eventType,
|
||||||
|
has_wizard_plan: Boolean(extractWizardPayload(payload)),
|
||||||
|
payload_keys: Object.keys(payload),
|
||||||
|
payload_preview: JSON.stringify(payload).substring(0, 200),
|
||||||
|
});
|
||||||
|
|
||||||
const wizardPayload = extractWizardPayload(payload);
|
const wizardPayload = extractWizardPayload(payload);
|
||||||
const hasWizardPlan = Boolean(wizardPayload);
|
const hasWizardPlan = Boolean(wizardPayload);
|
||||||
|
|
||||||
@@ -384,6 +437,10 @@ export default function StepWizardPlan({
|
|||||||
wizardPlanStatus: 'ready',
|
wizardPlanStatus: 'ready',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
source.close();
|
source.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -393,6 +450,10 @@ export default function StepWizardPlan({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
if (eventSourceRef.current) {
|
if (eventSourceRef.current) {
|
||||||
eventSourceRef.current.close();
|
eventSourceRef.current.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
@@ -415,37 +476,55 @@ export default function StepWizardPlan({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const validateUploads = (values: Record<string, any>) => {
|
const validateUploads = (values: Record<string, any>) => {
|
||||||
for (const [questionName, docs] of Object.entries(documentGroups)) {
|
// Проверяем каждый документ по его ID
|
||||||
if (!docs.length) continue;
|
for (const doc of documents) {
|
||||||
|
// Находим вопрос, к которому привязан документ
|
||||||
|
const questionName = Object.keys(documentGroups).find(key =>
|
||||||
|
documentGroups[key].some(d => d.id === doc.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!questionName) continue;
|
||||||
const answer = values?.[questionName];
|
const answer = values?.[questionName];
|
||||||
if (!isAffirmative(answer)) continue;
|
if (!isAffirmative(answer)) continue;
|
||||||
const blocks = questionFileBlocks[questionName] || [];
|
|
||||||
for (const doc of docs) {
|
// Блоки теперь хранятся по doc.id, а не по questionName
|
||||||
const matched = blocks.some((block) => {
|
const docKey = doc.id || doc.name || `doc_${questionName}`;
|
||||||
if (!block.files.length) return false;
|
const blocks = questionFileBlocks[docKey] || [];
|
||||||
if (!block.category) return true;
|
|
||||||
const normalizedCategory = block.category.toLowerCase();
|
// Проверяем, есть ли файлы для обязательного документа (если он не пропущен)
|
||||||
const normalizedId = (doc.id || '').toLowerCase();
|
if (doc.required) {
|
||||||
const normalizedName = (doc.name || '').toLowerCase();
|
if (skippedDocuments.has(docKey)) {
|
||||||
return (
|
continue; // Пропускаем валидацию для пропущенных документов
|
||||||
normalizedCategory === normalizedId ||
|
}
|
||||||
normalizedCategory === normalizedName ||
|
const hasFiles = blocks.some((block) => block.files.length > 0);
|
||||||
(normalizedCategory.includes('contract') && normalizedId.includes('contract')) ||
|
if (!hasFiles) {
|
||||||
(normalizedCategory.includes('payment') && normalizedId.includes('payment')) ||
|
return `Добавьте файлы для документа "${doc.name}" или отметьте, что документа нет`;
|
||||||
(normalizedCategory.includes('correspondence') && normalizedId.includes('correspondence'))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (doc.required && !matched) {
|
|
||||||
return `Добавьте файлы для документа "${doc.name}"`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const missingDescription = blocks.some(
|
|
||||||
(block) => block.files.length > 0 && !block.description?.trim()
|
// Проверяем описание только для необязательных документов И только если документ не предопределённый
|
||||||
);
|
// Предопределённые документы (contract, payment, payment_confirmation, receipt, cheque) не требуют описания
|
||||||
if (missingDescription) {
|
const docIdLower = (doc.id || '').toLowerCase();
|
||||||
return 'Заполните описание для каждого блока документов';
|
const docNameLower = (doc.name || '').toLowerCase();
|
||||||
|
const isPredefinedDoc = doc.id && !doc.id.includes('_exist') &&
|
||||||
|
(doc.id === 'contract' || doc.id === 'payment' || doc.id === 'payment_confirmation' ||
|
||||||
|
docIdLower.includes('contract') || docIdLower.includes('payment') ||
|
||||||
|
docIdLower.includes('receipt') || docIdLower.includes('cheque') ||
|
||||||
|
docNameLower.includes('договор') || docNameLower.includes('чек') ||
|
||||||
|
docNameLower.includes('оплат') || docNameLower.includes('платеж'));
|
||||||
|
|
||||||
|
// Для обязательных документов описание не требуется
|
||||||
|
// Для предопределённых документов описание не требуется
|
||||||
|
if (!doc.required && !isPredefinedDoc) {
|
||||||
|
const missingDescription = blocks.some(
|
||||||
|
(block) => block.files.length > 0 && !block.description?.trim()
|
||||||
|
);
|
||||||
|
if (missingDescription) {
|
||||||
|
return `Заполните описание для документа "${doc.name}"`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const customMissingDescription = customFileBlocks.some(
|
const customMissingDescription = customFileBlocks.some(
|
||||||
(block) => block.files.length > 0 && !block.description?.trim()
|
(block) => block.files.length > 0 && !block.description?.trim()
|
||||||
);
|
);
|
||||||
@@ -455,13 +534,14 @@ export default function StepWizardPlan({
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFinish = (values: Record<string, any>) => {
|
const handleFinish = async (values: Record<string, any>) => {
|
||||||
const uploadError = validateUploads(values);
|
const uploadError = validateUploads(values);
|
||||||
if (uploadError) {
|
if (uploadError) {
|
||||||
message.error(uploadError);
|
message.error(uploadError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сохраняем в общий стейт
|
||||||
updateFormData({
|
updateFormData({
|
||||||
wizardPlan: plan,
|
wizardPlan: plan,
|
||||||
wizardAnswers: values,
|
wizardAnswers: values,
|
||||||
@@ -470,14 +550,196 @@ export default function StepWizardPlan({
|
|||||||
documents: questionFileBlocks,
|
documents: questionFileBlocks,
|
||||||
custom: customFileBlocks,
|
custom: customFileBlocks,
|
||||||
},
|
},
|
||||||
|
wizardSkippedDocuments: Array.from(skippedDocuments),
|
||||||
});
|
});
|
||||||
|
|
||||||
addDebugEvent?.('wizard', 'info', '📝 Ответы на вопросы сохранены', {
|
addDebugEvent?.('wizard', 'info', '📝 Ответы на вопросы сохранены', {
|
||||||
answers: values,
|
answers: values,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Дёргаем вебхук через backend сразу после заполнения визарда (multipart/form-data)
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
addDebugEvent?.('wizard', 'info', '📤 Отправляем данные визарда в n8n', {
|
||||||
|
claim_id: formData.claim_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const formPayload = new FormData();
|
||||||
|
formPayload.append('stage', 'wizard');
|
||||||
|
formPayload.append('form_id', 'ticket_form');
|
||||||
|
if (formData.session_id) formPayload.append('session_id', formData.session_id);
|
||||||
|
if (formData.clientIp) formPayload.append('client_ip', formData.clientIp);
|
||||||
|
if (formData.smsCode) formPayload.append('sms_code', formData.smsCode);
|
||||||
|
if (formData.claim_id) formPayload.append('claim_id', formData.claim_id);
|
||||||
|
if (formData.contact_id) formPayload.append('contact_id', String(formData.contact_id));
|
||||||
|
if (formData.project_id) formPayload.append('project_id', String(formData.project_id));
|
||||||
|
if (typeof formData.is_new_contact !== 'undefined') {
|
||||||
|
formPayload.append('is_new_contact', String(formData.is_new_contact));
|
||||||
|
}
|
||||||
|
if (typeof formData.is_new_project !== 'undefined') {
|
||||||
|
formPayload.append('is_new_project', String(formData.is_new_project));
|
||||||
|
}
|
||||||
|
if (formData.phone) formPayload.append('phone', formData.phone);
|
||||||
|
if (formData.email) formPayload.append('email', formData.email);
|
||||||
|
if (formData.eventType) formPayload.append('event_type', formData.eventType);
|
||||||
|
|
||||||
|
// JSON-поля
|
||||||
|
formPayload.append('wizard_plan', JSON.stringify(plan || {}));
|
||||||
|
formPayload.append('wizard_answers', JSON.stringify(values || {}));
|
||||||
|
formPayload.append('wizard_skipped_documents', JSON.stringify(Array.from(skippedDocuments)));
|
||||||
|
|
||||||
|
// --- Группируем блоки в uploads[i][j] + uploads_descriptions[i] + uploads_field_names[i]
|
||||||
|
type UploadGroup = {
|
||||||
|
index: number;
|
||||||
|
question?: string;
|
||||||
|
block: FileBlock;
|
||||||
|
kind: 'question' | 'custom';
|
||||||
|
};
|
||||||
|
|
||||||
|
const groups: UploadGroup[] = [];
|
||||||
|
let groupIndex = 0;
|
||||||
|
|
||||||
|
// Собираем все блоки документов (теперь они хранятся по doc.id)
|
||||||
|
// Сначала ищем блоки, которые привязаны к вопросам через documentGroups
|
||||||
|
const allDocKeys = new Set<string>();
|
||||||
|
Object.values(documentGroups).forEach(docs => {
|
||||||
|
docs.forEach(doc => {
|
||||||
|
const docKey = doc.id || doc.name;
|
||||||
|
if (docKey && questionFileBlocks[docKey]) {
|
||||||
|
allDocKeys.add(docKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Также добавляем блоки по старым ключам (для обратной совместимости)
|
||||||
|
Object.keys(questionFileBlocks).forEach(key => {
|
||||||
|
if (!allDocKeys.has(key) && (key.includes('_exist') || key.startsWith('doc_'))) {
|
||||||
|
allDocKeys.add(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Array.from(allDocKeys).forEach((docKey) => {
|
||||||
|
const blocks = questionFileBlocks[docKey] || [];
|
||||||
|
blocks.forEach((block) => {
|
||||||
|
groups.push({
|
||||||
|
index: groupIndex++,
|
||||||
|
question: docKey, // Используем docKey как идентификатор
|
||||||
|
block,
|
||||||
|
kind: 'question',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Затем кастомные блоки
|
||||||
|
customFileBlocks.forEach((block) => {
|
||||||
|
groups.push({
|
||||||
|
index: groupIndex++,
|
||||||
|
question: 'custom',
|
||||||
|
block,
|
||||||
|
kind: 'custom',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const guessFieldName = (group: UploadGroup): string => {
|
||||||
|
const cat = (group.block.category || group.question || '').toLowerCase();
|
||||||
|
|
||||||
|
// Определяем имя поля на основе категории (которая теперь равна doc.id)
|
||||||
|
if (cat.includes('contract') || cat === 'contract' || cat === 'договор') {
|
||||||
|
return 'upload_contract';
|
||||||
|
}
|
||||||
|
if (cat.includes('payment') || cat.includes('cheque') || cat.includes('receipt') ||
|
||||||
|
cat.includes('подтверждение') || cat === 'payment_proof') {
|
||||||
|
return 'upload_payment';
|
||||||
|
}
|
||||||
|
if (cat.includes('correspondence') || cat.includes('chat') || cat.includes('переписка')) {
|
||||||
|
return 'upload_correspondence';
|
||||||
|
}
|
||||||
|
// Если категория похожа на ID документа, используем её
|
||||||
|
if (cat && !cat.includes('_exist')) {
|
||||||
|
return `upload_${cat.replace(/[^a-z0-9_]/g, '_')}`;
|
||||||
|
}
|
||||||
|
// Fallback на индекс
|
||||||
|
return `upload_${group.index}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
groups.forEach((group) => {
|
||||||
|
const i = group.index;
|
||||||
|
const block = group.block;
|
||||||
|
|
||||||
|
// Описание группы
|
||||||
|
formPayload.append(
|
||||||
|
`uploads_descriptions[${i}]`,
|
||||||
|
block.description || ''
|
||||||
|
);
|
||||||
|
|
||||||
|
// Имя "поля" группы
|
||||||
|
formPayload.append(
|
||||||
|
`uploads_field_names[${i}]`,
|
||||||
|
guessFieldName(group)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Файлы: uploads[i][j]
|
||||||
|
block.files.forEach((file, j) => {
|
||||||
|
const origin: any = (file as any).originFileObj;
|
||||||
|
if (!origin) return;
|
||||||
|
formPayload.append(`uploads[${i}][${j}]`, origin, origin.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch('/api/v1/claims/wizard', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
let parsed: any = null;
|
||||||
|
try {
|
||||||
|
parsed = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
parsed = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
message.error('Не удалось отправить данные визарда. Попробуйте ещё раз.');
|
||||||
|
addDebugEvent?.('wizard', 'error', '❌ Ошибка отправки визарда в n8n', {
|
||||||
|
status: response.status,
|
||||||
|
body: text,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addDebugEvent?.('wizard', 'success', '✅ Визард отправлен в n8n', {
|
||||||
|
response: parsed ?? text,
|
||||||
|
});
|
||||||
|
message.success('Мы изучаем ваш вопрос и документы.');
|
||||||
|
} catch (error) {
|
||||||
|
message.error('Ошибка соединения при отправке визарда.');
|
||||||
|
addDebugEvent?.('wizard', 'error', '❌ Ошибка соединения при отправке визарда', {
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
|
||||||
onNext();
|
onNext();
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderQuestionField = (question: WizardQuestion) => {
|
const renderQuestionField = (question: WizardQuestion) => {
|
||||||
|
// Обработка по input_type для более точного определения типа поля
|
||||||
|
if (question.input_type === 'multi_choice' || question.control === 'input[type="checkbox"]') {
|
||||||
|
return (
|
||||||
|
<Checkbox.Group>
|
||||||
|
<Space direction="vertical">
|
||||||
|
{question.options?.map((option) => (
|
||||||
|
<Checkbox key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Checkbox.Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
switch (question.control) {
|
switch (question.control) {
|
||||||
case 'textarea':
|
case 'textarea':
|
||||||
case 'input[type="textarea"]':
|
case 'input[type="textarea"]':
|
||||||
@@ -488,6 +750,14 @@ export default function StepWizardPlan({
|
|||||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case 'input[type="date"]':
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
size="large"
|
||||||
|
placeholder="Выберите дату"
|
||||||
|
/>
|
||||||
|
);
|
||||||
case 'input[type="radio"]':
|
case 'input[type="radio"]':
|
||||||
return (
|
return (
|
||||||
<Radio.Group>
|
<Radio.Group>
|
||||||
@@ -510,49 +780,93 @@ export default function StepWizardPlan({
|
|||||||
const docLabel = docList.map((doc) => doc.name).join(', ');
|
const docLabel = docList.map((doc) => doc.name).join(', ');
|
||||||
const accept = docList.flatMap((doc) => doc.accept || []);
|
const accept = docList.flatMap((doc) => doc.accept || []);
|
||||||
const uniqueAccept = Array.from(new Set(accept.length ? accept : ['pdf', 'jpg', 'png']));
|
const uniqueAccept = Array.from(new Set(accept.length ? accept : ['pdf', 'jpg', 'png']));
|
||||||
|
|
||||||
|
// Если документ предопределён (конкретный тип, не общий), не показываем лишние поля
|
||||||
|
// Предопределённые документы: contract, payment, payment_confirmation и их вариации
|
||||||
|
const doc = docList[0];
|
||||||
|
const isPredefinedDoc = docList.length === 1 && doc && doc.id &&
|
||||||
|
!doc.id.includes('_exist') &&
|
||||||
|
(doc.id === 'contract' || doc.id === 'payment' || doc.id === 'payment_confirmation' ||
|
||||||
|
doc.id.includes('contract') || doc.id.includes('payment') || doc.id.includes('receipt') ||
|
||||||
|
doc.id.includes('cheque') || doc.id.includes('чек'));
|
||||||
|
const singleDocName = isPredefinedDoc ? doc.name : null;
|
||||||
|
const isRequired = docList.some(doc => doc.required);
|
||||||
|
const isSkipped = skippedDocuments.has(docId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
{currentBlocks.map((block, idx) => (
|
{/* Чекбокс "Пропустить" для обязательных документов */}
|
||||||
|
{isRequired && (
|
||||||
|
<div style={{ marginBottom: 8, padding: 8, background: '#f8f9fa', borderRadius: 8 }}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSkipped}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newSkipped = new Set(skippedDocuments);
|
||||||
|
if (e.target.checked) {
|
||||||
|
newSkipped.add(docId);
|
||||||
|
} else {
|
||||||
|
newSkipped.delete(docId);
|
||||||
|
}
|
||||||
|
setSkippedDocuments(newSkipped);
|
||||||
|
updateFormData({ wizardSkippedDocuments: Array.from(newSkipped) });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
У меня нет этого документа
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isSkipped && currentBlocks.map((block, idx) => (
|
||||||
<Card
|
<Card
|
||||||
key={block.id}
|
key={block.id}
|
||||||
size="small"
|
size="small"
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 12,
|
borderRadius: 8,
|
||||||
border: '1px solid #e0e7ff',
|
border: '1px solid #d9d9d9',
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
}}
|
}}
|
||||||
title={`${docLabel} — группа #${idx + 1}`}
|
title={singleDocName || `${docLabel} — группа #${idx + 1}`}
|
||||||
extra={
|
extra={
|
||||||
<Button
|
currentBlocks.length > 1 && (
|
||||||
type="link"
|
<Button
|
||||||
danger
|
type="link"
|
||||||
size="small"
|
danger
|
||||||
onClick={() => removeDocumentBlock(docId, block.id)}
|
size="small"
|
||||||
>
|
onClick={() => removeDocumentBlock(docId, block.id)}
|
||||||
Удалить
|
>
|
||||||
</Button>
|
Удалить
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
<Input
|
{/* Поле описания только для необязательных/кастомных документов */}
|
||||||
placeholder="Описание документов (например: договор от 12.05, платёжка №123)"
|
{/* Для обязательных документов (contract, payment) описание не требуется */}
|
||||||
value={block.description}
|
{!isPredefinedDoc && !isRequired && (
|
||||||
onChange={(e) =>
|
<Input
|
||||||
updateDocumentBlock(docId, block.id, { description: e.target.value })
|
placeholder="Описание документов (например: договор от 12.05, платёжка №123)"
|
||||||
}
|
value={block.description}
|
||||||
/>
|
onChange={(e) =>
|
||||||
<Select
|
updateDocumentBlock(docId, block.id, { description: e.target.value })
|
||||||
value={block.category || docId}
|
}
|
||||||
onChange={(value) => updateDocumentBlock(docId, block.id, { category: value })}
|
/>
|
||||||
placeholder="Категория блока"
|
)}
|
||||||
>
|
|
||||||
{documentCategoryOptions.map((option) => (
|
{/* Выпадашка категорий только для общих вопросов (docs_exist, correspondence_exist) */}
|
||||||
<Option key={`${docId}-${option.value}`} value={option.value}>
|
{!isPredefinedDoc && (
|
||||||
{option.label}
|
<Select
|
||||||
</Option>
|
value={block.category || docId}
|
||||||
))}
|
onChange={(value) => updateDocumentBlock(docId, block.id, { category: value })}
|
||||||
</Select>
|
placeholder="Категория блока"
|
||||||
|
>
|
||||||
|
{documentCategoryOptions.map((option) => (
|
||||||
|
<Option key={`${docId}-${option.value}`} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
|
||||||
<Dragger
|
<Dragger
|
||||||
multiple
|
multiple
|
||||||
beforeUpload={() => false}
|
beforeUpload={() => false}
|
||||||
@@ -561,10 +875,10 @@ export default function StepWizardPlan({
|
|||||||
updateDocumentBlock(docId, block.id, { files: fileList })
|
updateDocumentBlock(docId, block.id, { files: fileList })
|
||||||
}
|
}
|
||||||
accept={uniqueAccept.map((ext) => `.${ext}`).join(',')}
|
accept={uniqueAccept.map((ext) => `.${ext}`).join(',')}
|
||||||
style={{ background: '#f8f9ff' }}
|
style={{ background: '#fafafa' }}
|
||||||
>
|
>
|
||||||
<p className="ant-upload-drag-icon">
|
<p className="ant-upload-drag-icon">
|
||||||
<LoadingOutlined style={{ color: '#6366f1' }} />
|
<LoadingOutlined style={{ color: '#595959' }} />
|
||||||
</p>
|
</p>
|
||||||
<p className="ant-upload-text">Перетащите файлы или нажмите для загрузки</p>
|
<p className="ant-upload-text">Перетащите файлы или нажмите для загрузки</p>
|
||||||
<p className="ant-upload-hint">
|
<p className="ant-upload-hint">
|
||||||
@@ -574,13 +888,18 @@ export default function StepWizardPlan({
|
|||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
<Button
|
{/* Кнопка "Добавить" только если документ не пропущен */}
|
||||||
icon={<PlusOutlined />}
|
{!isSkipped && (!isPredefinedDoc || currentBlocks.length === 0) && (
|
||||||
onClick={() => addDocumentBlock(docId, docLabel)}
|
<Button
|
||||||
style={{ width: '100%' }}
|
icon={<PlusOutlined />}
|
||||||
>
|
onClick={() => addDocumentBlock(docId, docLabel, docList)}
|
||||||
Добавить документы ({docLabel})
|
style={{ width: '100%' }}
|
||||||
</Button>
|
>
|
||||||
|
{isPredefinedDoc && currentBlocks.length === 0
|
||||||
|
? `Загрузить ${singleDocName || docLabel}`
|
||||||
|
: `Добавить документы (${docLabel})`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -588,8 +907,8 @@ export default function StepWizardPlan({
|
|||||||
const renderCustomUploads = () => (
|
const renderCustomUploads = () => (
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
style={{ marginTop: 24, borderRadius: 12, border: '1px solid #e0e7ff' }}
|
style={{ marginTop: 24, borderRadius: 8, border: '1px solid #d9d9d9' }}
|
||||||
title="Дополнительные документы"
|
title="Документы"
|
||||||
extra={
|
extra={
|
||||||
<Button type="link" onClick={addCustomBlock} icon={<PlusOutlined />}>
|
<Button type="link" onClick={addCustomBlock} icon={<PlusOutlined />}>
|
||||||
Добавить блок
|
Добавить блок
|
||||||
@@ -642,7 +961,7 @@ export default function StepWizardPlan({
|
|||||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.heic"
|
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.heic"
|
||||||
>
|
>
|
||||||
<p className="ant-upload-drag-icon">
|
<p className="ant-upload-drag-icon">
|
||||||
<LoadingOutlined style={{ color: '#6366f1' }} />
|
<LoadingOutlined style={{ color: '#595959' }} />
|
||||||
</p>
|
</p>
|
||||||
<p className="ant-upload-text">Перетащите файлы или нажмите для загрузки</p>
|
<p className="ant-upload-text">Перетащите файлы или нажмите для загрузки</p>
|
||||||
<p className="ant-upload-hint">Максимум 10 файлов, до 20 МБ каждый.</p>
|
<p className="ant-upload-hint">Максимум 10 файлов, до 20 МБ каждый.</p>
|
||||||
@@ -663,7 +982,7 @@ export default function StepWizardPlan({
|
|||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
style={{ marginBottom: 16, borderRadius: 12, border: '1px solid #e0e7ff' }}
|
style={{ marginBottom: 16, borderRadius: 8, border: '1px solid #d9d9d9' }}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" style={{ width: '100%' }}>
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
@@ -681,44 +1000,94 @@ export default function StepWizardPlan({
|
|||||||
onFinish={handleFinish}
|
onFinish={handleFinish}
|
||||||
initialValues={{ ...prefillMap, ...formData.wizardAnswers }}
|
initialValues={{ ...prefillMap, ...formData.wizardAnswers }}
|
||||||
>
|
>
|
||||||
{questions.map((question) => (
|
{questions.map((question) => {
|
||||||
<Form.Item shouldUpdate key={question.name}>
|
// Для условных полей используем dependencies для отслеживания изменений
|
||||||
{() => {
|
const dependencies = question.ask_if ? [question.ask_if.field] : undefined;
|
||||||
const values = form.getFieldsValue(true);
|
|
||||||
if (!evaluateCondition(question.ask_if, values)) {
|
return (
|
||||||
return null;
|
<Form.Item
|
||||||
}
|
key={question.name}
|
||||||
const questionDocs = documentGroups[question.name] || [];
|
dependencies={dependencies}
|
||||||
const questionValue = values?.[question.name];
|
shouldUpdate={dependencies ? (prev, curr) => {
|
||||||
return (
|
// Обновляем только если изменилось значение поля, от которого зависит вопрос
|
||||||
<>
|
return prev[question.ask_if!.field] !== curr[question.ask_if!.field];
|
||||||
<Form.Item
|
} : undefined}
|
||||||
label={question.label}
|
>
|
||||||
name={question.name}
|
{() => {
|
||||||
rules={[
|
const values = form.getFieldsValue(true);
|
||||||
{
|
if (!evaluateCondition(question.ask_if, values)) {
|
||||||
required: question.required,
|
return null;
|
||||||
message: 'Поле обязательно для заполнения',
|
}
|
||||||
},
|
const questionDocs = documentGroups[question.name] || [];
|
||||||
]}
|
const questionValue = values?.[question.name];
|
||||||
>
|
|
||||||
{renderQuestionField(question)}
|
// Скрываем вопросы, которые связаны с загрузкой документов
|
||||||
</Form.Item>
|
// Если в плане визарда есть документы, не показываем поля про загрузку (text/textarea/file)
|
||||||
{questionDocs.length > 0 && isAffirmative(questionValue) && (
|
const questionLabelLower = (question.label || '').toLowerCase();
|
||||||
<div style={{ marginBottom: 24 }}>
|
const questionNameLower = (question.name || '').toLowerCase();
|
||||||
<Text strong>Загрузите документы:</Text>
|
const isDocumentUploadQuestion =
|
||||||
{renderDocumentBlocks(question.name, questionDocs)}
|
(question.input_type === 'text' ||
|
||||||
</div>
|
question.input_type === 'textarea' ||
|
||||||
)}
|
question.input_type === 'file') &&
|
||||||
</>
|
(questionLabelLower.includes('загрузите') ||
|
||||||
);
|
questionLabelLower.includes('фото') ||
|
||||||
}}
|
questionLabelLower.includes('сканы') ||
|
||||||
</Form.Item>
|
questionLabelLower.includes('документ') ||
|
||||||
))}
|
questionLabelLower.includes('договор') ||
|
||||||
|
questionLabelLower.includes('чек') ||
|
||||||
|
questionLabelLower.includes('платеж') ||
|
||||||
|
questionLabelLower.includes('копии') ||
|
||||||
|
questionLabelLower.includes('переписк') ||
|
||||||
|
questionNameLower.includes('upload') ||
|
||||||
|
questionNameLower.includes('document'));
|
||||||
|
|
||||||
|
// Если это вопрос про загрузку документов И в плане есть документы, не показываем поле
|
||||||
|
// (даже если вопрос не связан с documentGroups)
|
||||||
|
// Загрузка файлов уже реализована через блоки документов (documents)
|
||||||
|
if (isDocumentUploadQuestion && documents.length > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
label={question.label}
|
||||||
|
name={question.name}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: question.required,
|
||||||
|
message: 'Поле обязательно для заполнения',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{renderQuestionField(question)}
|
||||||
|
</Form.Item>
|
||||||
|
{questionDocs.length > 0 && isAffirmative(questionValue) && (
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Text strong>Загрузите документы:</Text>
|
||||||
|
<Space direction="vertical" style={{ width: '100%', marginTop: 16 }}>
|
||||||
|
{questionDocs.map((doc) => {
|
||||||
|
// Используем doc.id как ключ для отдельного хранения блоков каждого документа
|
||||||
|
const docKey = doc.id || doc.name || `doc_${question.name}`;
|
||||||
|
return (
|
||||||
|
<div key={doc.id}>
|
||||||
|
{renderDocumentBlocks(docKey, [doc])}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
<Space style={{ marginTop: 24 }}>
|
<Space style={{ marginTop: 24 }}>
|
||||||
<Button onClick={onPrev}>← Назад</Button>
|
<Button onClick={onPrev}>← Назад</Button>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit" loading={submitting}>
|
||||||
Сохранить и продолжить →
|
Сохранить и продолжить →
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
@@ -751,9 +1120,9 @@ export default function StepWizardPlan({
|
|||||||
|
|
||||||
<Card
|
<Card
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 16,
|
borderRadius: 8,
|
||||||
border: '1px solid #dbeafe',
|
border: '1px solid #d9d9d9',
|
||||||
background: '#f8fbff',
|
background: '#fafafa',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isWaiting && (
|
{isWaiting && (
|
||||||
@@ -791,7 +1160,7 @@ export default function StepWizardPlan({
|
|||||||
{!isWaiting && plan && (
|
{!isWaiting && plan && (
|
||||||
<div>
|
<div>
|
||||||
<Title level={4} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<Title level={4} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<ThunderboltOutlined style={{ color: '#6366f1' }} /> План действий
|
<ThunderboltOutlined style={{ color: '#595959' }} /> План действий
|
||||||
</Title>
|
</Title>
|
||||||
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
|
<Paragraph type="secondary" style={{ marginBottom: 24 }}>
|
||||||
{plan.user_text || 'Ответьте на вопросы и подготовьте документы, чтобы мы могли продолжить.'}
|
{plan.user_text || 'Ответьте на вопросы и подготовьте документы, чтобы мы могли продолжить.'}
|
||||||
@@ -801,9 +1170,9 @@ export default function StepWizardPlan({
|
|||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 12,
|
borderRadius: 8,
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
border: '1px solid #e0e7ff',
|
border: '1px solid #d9d9d9',
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
}}
|
}}
|
||||||
title="Документы, которые понадобятся"
|
title="Документы, которые понадобятся"
|
||||||
@@ -844,3 +1213,4 @@ export default function StepWizardPlan({
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ body {
|
|||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background: #f5f5f5;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
|
|||||||
@@ -225,3 +225,4 @@ export type WizardPlanSample = typeof wizardPlanSample;
|
|||||||
|
|
||||||
export default wizardPlanSample;
|
export default wizardPlanSample;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.claim-form-container {
|
.claim-form-container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 40px 20px;
|
padding: 40px 20px;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: #ffffff;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -10,18 +10,20 @@
|
|||||||
.claim-form-card {
|
.claim-form-card {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
border-radius: 12px;
|
border-radius: 8px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.claim-form-card .ant-card-head {
|
.claim-form-card .ant-card-head {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: #fafafa;
|
||||||
color: white;
|
color: #000000;
|
||||||
border-radius: 12px 12px 0 0;
|
border-bottom: 1px solid #d9d9d9;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.claim-form-card .ant-card-head-title {
|
.claim-form-card .ant-card-head-title {
|
||||||
color: white;
|
color: #000000;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Steps, Card, message, Row, Col } from 'antd';
|
|||||||
import Step1Phone from '../components/form/Step1Phone';
|
import Step1Phone from '../components/form/Step1Phone';
|
||||||
import StepDescription from '../components/form/StepDescription';
|
import StepDescription from '../components/form/StepDescription';
|
||||||
import Step1Policy from '../components/form/Step1Policy';
|
import Step1Policy from '../components/form/Step1Policy';
|
||||||
|
import StepDraftSelection from '../components/form/StepDraftSelection';
|
||||||
import StepWizardPlan from '../components/form/StepWizardPlan';
|
import StepWizardPlan from '../components/form/StepWizardPlan';
|
||||||
import Step2EventType from '../components/form/Step2EventType';
|
import Step2EventType from '../components/form/Step2EventType';
|
||||||
import StepDocumentUpload from '../components/form/StepDocumentUpload';
|
import StepDocumentUpload from '../components/form/StepDocumentUpload';
|
||||||
@@ -11,7 +12,7 @@ import DebugPanel from '../components/DebugPanel';
|
|||||||
import { getDocumentsForEventType } from '../constants/documentConfigs';
|
import { getDocumentsForEventType } from '../constants/documentConfigs';
|
||||||
import './ClaimForm.css';
|
import './ClaimForm.css';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8200';
|
// Используем относительные пути - Vite proxy перенаправит на backend
|
||||||
|
|
||||||
const { Step } = Steps;
|
const { Step } = Steps;
|
||||||
|
|
||||||
@@ -19,7 +20,11 @@ interface FormData {
|
|||||||
// Шаг 1: Phone
|
// Шаг 1: Phone
|
||||||
phone?: string;
|
phone?: string;
|
||||||
contact_id?: string;
|
contact_id?: string;
|
||||||
|
unified_id?: string; // ✅ Unified ID пользователя из PostgreSQL
|
||||||
is_new_contact?: boolean;
|
is_new_contact?: boolean;
|
||||||
|
smsCode?: string;
|
||||||
|
clientIp?: string;
|
||||||
|
smsDebugCode?: string;
|
||||||
|
|
||||||
// Шаг 2: Policy
|
// Шаг 2: Policy
|
||||||
voucher: string;
|
voucher: string;
|
||||||
@@ -35,6 +40,7 @@ interface FormData {
|
|||||||
wizardPrefillArray?: Array<{ name: string; value: any }>;
|
wizardPrefillArray?: Array<{ name: string; value: any }>;
|
||||||
wizardCoverageReport?: any;
|
wizardCoverageReport?: any;
|
||||||
wizardUploads?: Record<string, any>;
|
wizardUploads?: Record<string, any>;
|
||||||
|
wizardSkippedDocuments?: string[];
|
||||||
|
|
||||||
// Шаг 3: Event Type
|
// Шаг 3: Event Type
|
||||||
eventType?: string;
|
eventType?: string;
|
||||||
@@ -81,12 +87,33 @@ export default function ClaimForm() {
|
|||||||
});
|
});
|
||||||
const [isPhoneVerified, setIsPhoneVerified] = useState(false);
|
const [isPhoneVerified, setIsPhoneVerified] = useState(false);
|
||||||
const [debugEvents, setDebugEvents] = useState<any[]>([]);
|
const [debugEvents, setDebugEvents] = useState<any[]>([]);
|
||||||
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
const [showDraftSelection, setShowDraftSelection] = useState(false);
|
||||||
|
const [selectedDraftId, setSelectedDraftId] = useState<string | null>(null);
|
||||||
|
const [hasDrafts, setHasDrafts] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился!
|
// 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился!
|
||||||
console.log('🔥 ClaimForm v2.0 - claim_id НЕ генерируется на фронте!');
|
console.log('🔥 ClaimForm v2.0 - claim_id НЕ генерируется на фронте!');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Получаем IP клиента один раз при монтировании
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchClientIp = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/utils/client-ip');
|
||||||
|
if (!response.ok) return;
|
||||||
|
const data = await response.json();
|
||||||
|
if (data?.ip) {
|
||||||
|
setFormData((prev) => ({ ...prev, clientIp: data.ip }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Тихо игнорируем, IP всегда можно взять на бэке из request
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchClientIp();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Динамически определяем список шагов на основе выбранного eventType
|
// Динамически определяем список шагов на основе выбранного eventType
|
||||||
const documentConfigs = formData.eventType ? getDocumentsForEventType(formData.eventType) : [];
|
const documentConfigs = formData.eventType ? getDocumentsForEventType(formData.eventType) : [];
|
||||||
const totalDocumentSteps = documentConfigs.length;
|
const totalDocumentSteps = documentConfigs.length;
|
||||||
@@ -127,51 +154,196 @@ export default function ClaimForm() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Загрузка черновика
|
||||||
|
const loadDraft = useCallback(async (claimId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/claims/drafts/${claimId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Не удалось загрузить черновик');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const claim = data.claim;
|
||||||
|
const payload = claim.payload || {};
|
||||||
|
|
||||||
|
// Восстанавливаем данные формы из черновика
|
||||||
|
updateFormData({
|
||||||
|
claim_id: claim.claim_id,
|
||||||
|
session_id: claim.session_token || sessionId,
|
||||||
|
phone: payload.phone || formData.phone,
|
||||||
|
email: payload.email || formData.email,
|
||||||
|
problemDescription: payload.problem_description || formData.problemDescription,
|
||||||
|
wizardPlan: payload.wizard_plan || formData.wizardPlan,
|
||||||
|
wizardAnswers: payload.answers || formData.wizardAnswers,
|
||||||
|
wizardPrefill: payload.answers_prefill ?
|
||||||
|
payload.answers_prefill.reduce((acc: any, item: any) => {
|
||||||
|
acc[item.name] = item.value;
|
||||||
|
return acc;
|
||||||
|
}, {}) : formData.wizardPrefill,
|
||||||
|
wizardPrefillArray: payload.answers_prefill || formData.wizardPrefillArray,
|
||||||
|
wizardCoverageReport: payload.coverage_report || formData.wizardCoverageReport,
|
||||||
|
wizardUploads: {
|
||||||
|
documents: payload.documents_meta ? {} : formData.wizardUploads?.documents,
|
||||||
|
custom: formData.wizardUploads?.custom || [],
|
||||||
|
},
|
||||||
|
wizardSkippedDocuments: payload.wizard_skipped_documents || formData.wizardSkippedDocuments,
|
||||||
|
eventType: payload.event_type || formData.eventType,
|
||||||
|
contact_id: payload.contact_id || formData.contact_id,
|
||||||
|
project_id: payload.project_id || formData.project_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedDraftId(claimId);
|
||||||
|
setShowDraftSelection(false);
|
||||||
|
|
||||||
|
// Переходим к шагу с описанием, если оно есть, иначе к шагу с рекомендациями
|
||||||
|
if (payload.problem_description) {
|
||||||
|
// Если есть описание, переходим к шагу с рекомендациями
|
||||||
|
setCurrentStep(2); // StepWizardPlan
|
||||||
|
} else {
|
||||||
|
// Если нет описания, переходим к шагу с описанием
|
||||||
|
setCurrentStep(1); // StepDescription
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки черновика:', error);
|
||||||
|
message.error('Не удалось загрузить черновик');
|
||||||
|
}
|
||||||
|
}, [formData, sessionId, updateFormData]);
|
||||||
|
|
||||||
|
// Обработчик выбора черновика
|
||||||
|
const handleSelectDraft = useCallback((claimId: string) => {
|
||||||
|
loadDraft(claimId);
|
||||||
|
}, [loadDraft]);
|
||||||
|
|
||||||
|
// Проверка наличия черновиков
|
||||||
|
const checkDrafts = useCallback(async (unified_id?: string, phone?: string, sessionId?: string) => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
// Приоритет: unified_id > phone > session_id
|
||||||
|
if (unified_id) {
|
||||||
|
params.append('unified_id', unified_id);
|
||||||
|
} else if (phone) {
|
||||||
|
params.append('phone', phone);
|
||||||
|
} else if (sessionId) {
|
||||||
|
params.append('session_id', sessionId);
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `/api/v1/claims/drafts/list?${params.toString()}`;
|
||||||
|
console.log('🔍 Запрос черновиков:', url);
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('❌ Ошибка запроса черновиков:', response.status, response.statusText);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('🔍 Ответ API черновиков:', data);
|
||||||
|
const count = data.count || 0;
|
||||||
|
console.log('🔍 Количество черновиков:', count);
|
||||||
|
|
||||||
|
setHasDrafts(count > 0);
|
||||||
|
setShowDraftSelection(count > 0);
|
||||||
|
return count > 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка проверки черновиков:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Обработчик создания новой заявки
|
||||||
|
const handleNewClaim = useCallback(() => {
|
||||||
|
setShowDraftSelection(false);
|
||||||
|
setSelectedDraftId(null);
|
||||||
|
// Очищаем данные формы, кроме телефона и session_id
|
||||||
|
updateFormData({
|
||||||
|
claim_id: undefined,
|
||||||
|
problemDescription: undefined,
|
||||||
|
wizardPlan: undefined,
|
||||||
|
wizardAnswers: undefined,
|
||||||
|
wizardPrefill: undefined,
|
||||||
|
wizardPrefillArray: undefined,
|
||||||
|
wizardCoverageReport: undefined,
|
||||||
|
wizardUploads: undefined,
|
||||||
|
wizardSkippedDocuments: undefined,
|
||||||
|
eventType: undefined,
|
||||||
|
});
|
||||||
|
// Переходим к шагу с описанием
|
||||||
|
setCurrentStep(1);
|
||||||
|
}, [updateFormData]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
addDebugEvent('form', 'info', '📤 Отправка заявки на сервер');
|
addDebugEvent('form', 'info', '📤 Отправка заявки в n8n через backend');
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/api/v1/claims/create`, {
|
const payload = {
|
||||||
|
stage: 'final',
|
||||||
|
form_id: 'ticket_form',
|
||||||
|
session_id: formData.session_id ?? sessionId,
|
||||||
|
client_ip: formData.clientIp,
|
||||||
|
sms_code: formData.smsCode,
|
||||||
|
|
||||||
|
// Базовые идентификаторы
|
||||||
|
claim_id: formData.claim_id,
|
||||||
|
contact_id: formData.contact_id,
|
||||||
|
project_id: formData.project_id,
|
||||||
|
ticket_id: formData.ticket_id,
|
||||||
|
is_new_contact: formData.is_new_contact,
|
||||||
|
is_new_project: formData.is_new_project,
|
||||||
|
|
||||||
|
// Основные поля формы (для удобства в n8n)
|
||||||
|
voucher: formData.voucher,
|
||||||
|
phone: formData.phone,
|
||||||
|
email: formData.email,
|
||||||
|
event_type: formData.eventType,
|
||||||
|
payment_method: formData.paymentMethod,
|
||||||
|
bank_name: formData.bankName,
|
||||||
|
card_number: formData.cardNumber,
|
||||||
|
account_number: formData.accountNumber,
|
||||||
|
|
||||||
|
// Старый блок документов + новые загрузки визарда (пока как есть)
|
||||||
|
documents: formData.documents || {},
|
||||||
|
wizard_uploads: formData.wizardUploads || {},
|
||||||
|
|
||||||
|
// Всё состояние формы целиком — на всякий случай
|
||||||
|
form: formData,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('/api/v1/claims/create', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
claim_id: formData.claim_id, // ✅ Используем claim_id от n8n
|
|
||||||
voucher: formData.voucher,
|
|
||||||
email: formData.email,
|
|
||||||
phone: formData.phone,
|
|
||||||
event_type: formData.eventType,
|
|
||||||
payment_method: formData.paymentMethod,
|
|
||||||
bank_name: formData.bankName,
|
|
||||||
card_number: formData.cardNumber,
|
|
||||||
account_number: formData.accountNumber,
|
|
||||||
documents: formData.documents || {},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const text = await response.text();
|
||||||
|
let parsed: any = null;
|
||||||
if (result.success) {
|
try {
|
||||||
message.success(`Заявка ${result.claim_number} успешно создана!`);
|
parsed = text ? JSON.parse(text) : null;
|
||||||
addDebugEvent('form', 'success', `✅ Заявка ${result.claim_number} создана`);
|
} catch {
|
||||||
|
parsed = null;
|
||||||
// Сброс формы (создаём новую заявку, claim_id будет сгенерирован при следующем SMS)
|
|
||||||
setFormData({
|
|
||||||
voucher: '',
|
|
||||||
claim_id: undefined, // ✅ Очищаем для новой заявки
|
|
||||||
session_id: sessionId,
|
|
||||||
paymentMethod: 'sbp',
|
|
||||||
});
|
|
||||||
setCurrentStep(0);
|
|
||||||
setIsPhoneVerified(false);
|
|
||||||
} else {
|
|
||||||
message.error('Ошибка при создании заявки');
|
|
||||||
addDebugEvent('form', 'error', '❌ Ошибка создания заявки');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
message.error('Ошибка при создании заявки (n8n)');
|
||||||
|
addDebugEvent('form', 'error', '❌ Ошибка создания заявки в n8n', {
|
||||||
|
status: response.status,
|
||||||
|
body: text,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addDebugEvent('form', 'success', '✅ Финальный webhook в n8n отработал', {
|
||||||
|
response: parsed ?? text,
|
||||||
|
});
|
||||||
|
// Помечаем, что заявка отправлена, и показываем заглушку.
|
||||||
|
setIsSubmitted(true);
|
||||||
|
message.success('Данные отправлены, заявка принята в обработку.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('Ошибка соединения с сервером');
|
message.error('Ошибка соединения с сервером');
|
||||||
addDebugEvent('form', 'error', '❌ Ошибка соединения');
|
addDebugEvent('form', 'error', '❌ Ошибка соединения', { error: String(error) });
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}, [formData, sessionId, addDebugEvent]);
|
}, [formData, sessionId, addDebugEvent]);
|
||||||
@@ -180,6 +352,22 @@ export default function ClaimForm() {
|
|||||||
const steps = useMemo(() => {
|
const steps = useMemo(() => {
|
||||||
const stepsArray: any[] = [];
|
const stepsArray: any[] = [];
|
||||||
|
|
||||||
|
// Шаг 0: Выбор черновика (показывается только если есть черновики и телефон верифицирован)
|
||||||
|
if (showDraftSelection && isPhoneVerified && !selectedDraftId && hasDrafts) {
|
||||||
|
stepsArray.push({
|
||||||
|
title: 'Черновики',
|
||||||
|
description: 'Выбор заявки',
|
||||||
|
content: (
|
||||||
|
<StepDraftSelection
|
||||||
|
phone={formData.phone || ''}
|
||||||
|
session_id={sessionId}
|
||||||
|
onSelectDraft={handleSelectDraft}
|
||||||
|
onNewClaim={handleNewClaim}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Шаг 1: Phone (телефон + SMS верификация)
|
// Шаг 1: Phone (телефон + SMS верификация)
|
||||||
stepsArray.push({
|
stepsArray.push({
|
||||||
title: 'Телефон',
|
title: 'Телефон',
|
||||||
@@ -187,11 +375,52 @@ export default function ClaimForm() {
|
|||||||
content: (
|
content: (
|
||||||
<Step1Phone
|
<Step1Phone
|
||||||
formData={{ ...formData, session_id: sessionId }} // ✅ claim_id будет создан n8n
|
formData={{ ...formData, session_id: sessionId }} // ✅ claim_id будет создан n8n
|
||||||
updateFormData={updateFormData}
|
updateFormData={(data: any) => {
|
||||||
onNext={nextStep}
|
updateFormData(data);
|
||||||
|
// После верификации телефона проверяем черновики
|
||||||
|
if (data.phone && isPhoneVerified && !selectedDraftId && !showDraftSelection) {
|
||||||
|
setShowDraftSelection(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onNext={async (unified_id?: string) => {
|
||||||
|
console.log('🔥 onNext вызван с unified_id:', unified_id);
|
||||||
|
console.log('🔥 formData.unified_id:', formData.unified_id);
|
||||||
|
console.log('🔥 isPhoneVerified:', isPhoneVerified);
|
||||||
|
console.log('🔥 selectedDraftId:', selectedDraftId);
|
||||||
|
|
||||||
|
// После верификации проверяем черновики
|
||||||
|
// Используем unified_id из параметра (если передан) или из formData
|
||||||
|
const finalUnifiedId = unified_id || formData.unified_id;
|
||||||
|
console.log('🔥 finalUnifiedId:', finalUnifiedId);
|
||||||
|
|
||||||
|
if (formData.phone && isPhoneVerified && !selectedDraftId) {
|
||||||
|
console.log('🔍 Проверка черновиков с unified_id:', finalUnifiedId, 'phone:', formData.phone);
|
||||||
|
const hasDraftsResult = await checkDrafts(finalUnifiedId, formData.phone, sessionId);
|
||||||
|
console.log('🔍 Результат checkDrafts:', hasDraftsResult);
|
||||||
|
if (hasDraftsResult) {
|
||||||
|
console.log('✅ Есть черновики, переходим к шагу 0');
|
||||||
|
setCurrentStep(0); // Переходим к шагу выбора черновика
|
||||||
|
} else {
|
||||||
|
console.log('❌ Нет черновиков, идем дальше');
|
||||||
|
nextStep(); // Нет черновиков, идем дальше
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Условие не выполнено, идем дальше');
|
||||||
|
nextStep();
|
||||||
|
}
|
||||||
|
}}
|
||||||
onPrev={prevStep}
|
onPrev={prevStep}
|
||||||
isPhoneVerified={isPhoneVerified}
|
isPhoneVerified={isPhoneVerified}
|
||||||
setIsPhoneVerified={setIsPhoneVerified}
|
setIsPhoneVerified={async (verified: boolean) => {
|
||||||
|
setIsPhoneVerified(verified);
|
||||||
|
// После верификации проверяем черновики
|
||||||
|
if (verified && formData.phone && !selectedDraftId) {
|
||||||
|
const hasDraftsResult = await checkDrafts(formData.unified_id, formData.phone, sessionId);
|
||||||
|
if (hasDraftsResult) {
|
||||||
|
setCurrentStep(0); // Переходим к шагу выбора черновика
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
addDebugEvent={addDebugEvent}
|
addDebugEvent={addDebugEvent}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -296,9 +525,10 @@ export default function ClaimForm() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return stepsArray;
|
return stepsArray;
|
||||||
}, [formData, documentConfigs, isPhoneVerified, sessionId, nextStep, prevStep, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent]);
|
}, [formData, documentConfigs, isPhoneVerified, sessionId, nextStep, prevStep, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts]);
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
|
setIsSubmitted(false);
|
||||||
setFormData({
|
setFormData({
|
||||||
voucher: '',
|
voucher: '',
|
||||||
claim_id: undefined, // ✅ Очищаем для новой заявки
|
claim_id: undefined, // ✅ Очищаем для новой заявки
|
||||||
@@ -312,7 +542,7 @@ export default function ClaimForm() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="claim-form-container" style={{ padding: '20px', background: '#f0f2f5' }}>
|
<div className="claim-form-container" style={{ padding: '20px', background: '#ffffff' }}>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
{/* Левая часть - Форма */}
|
{/* Левая часть - Форма */}
|
||||||
<Col xs={24} lg={14}>
|
<Col xs={24} lg={14}>
|
||||||
@@ -320,7 +550,7 @@ export default function ClaimForm() {
|
|||||||
title="Подать заявку на выплату"
|
title="Подать заявку на выплату"
|
||||||
className="claim-form-card"
|
className="claim-form-card"
|
||||||
extra={
|
extra={
|
||||||
currentStep > 0 && (
|
!isSubmitted && currentStep > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
style={{
|
style={{
|
||||||
@@ -337,16 +567,27 @@ export default function ClaimForm() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Steps current={currentStep} className="steps">
|
{isSubmitted ? (
|
||||||
{steps.map((item, index) => (
|
<div style={{ padding: '40px 0', textAlign: 'center' }}>
|
||||||
<Step
|
<h3 style={{ fontSize: 22, marginBottom: 8 }}>Мы изучаем ваш вопрос и документы</h3>
|
||||||
key={`step-${index}`}
|
<p style={{ color: '#666666', maxWidth: 480, margin: '0 auto 24px' }}>
|
||||||
title={item.title}
|
Заявка отправлена в работу. Юристы проверят информацию и свяжутся с вами по указанным контактам.
|
||||||
description={item.description}
|
</p>
|
||||||
/>
|
</div>
|
||||||
))}
|
) : (
|
||||||
</Steps>
|
<>
|
||||||
<div className="steps-content">{steps[currentStep].content}</div>
|
<Steps current={currentStep} className="steps">
|
||||||
|
{steps.map((item, index) => (
|
||||||
|
<Step
|
||||||
|
key={`step-${index}`}
|
||||||
|
title={item.title}
|
||||||
|
description={item.description}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Steps>
|
||||||
|
<div className="steps-content">{steps[currentStep].content}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
|
|||||||
1
frontend/src/vite-env.d.ts
vendored
1
frontend/src/vite-env.d.ts
vendored
@@ -5,3 +5,4 @@ declare module '*.svg' {
|
|||||||
export default content;
|
export default content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user