Добавлено логирование для отладки черновиков

- Добавлены логи в frontend (ClaimForm.tsx) для отслеживания unified_id и запросов к API
- Добавлены логи в backend (claims.py) для отладки SQL запросов
- Создан лог сессии с описанием проблемы и текущего состояния
- Проблема: API возвращает 0 черновиков, хотя в БД есть данные
This commit is contained in:
AI Assistant
2025-11-19 18:46:48 +03:00
parent cbab1c0fe6
commit 4c8fda5f55
57 changed files with 6574 additions and 304 deletions

View File

@@ -550,3 +550,4 @@ Last commit: c049ed6 - "fix: Добавлены n8n webhook URLs в docker-compo

View File

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

View File

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

View File

@@ -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
View 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! 🚀

View File

@@ -127,3 +127,4 @@ tail -f /var/www/fastuser/data/www/crm.clientright.ru/logs/upload_documents.log
Эндпоинт готов к интеграции в n8n workflow! Эндпоинт готов к интеграции в n8n workflow!

View File

@@ -31,3 +31,4 @@ curl -X POST "https://crm.clientright.ru/api/n8n/documents/attach" \
echo "" echo ""
echo "✅ Тесты завершены!" echo "✅ Тесты завершены!"

View File

@@ -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()}"
# TODO: Сохранить в PostgreSQL data: dict[str, str] = {}
# TODO: Отправить в очередь RabbitMQ для обработки files: dict[str, tuple] = {}
# TODO: Интеграция с CRM
return ClaimResponse( for key, value in form.multi_items():
success=True, # В starlette UploadFile — это другой класс, чем fastapi.UploadFile,
claim_id=claim_id, # поэтому проверяем по наличию атрибутов, а не по isinstance.
claim_number=claim_number, if hasattr(value, "filename") and hasattr(value, "read"):
message=f"Заявка {claim_number} успешно создана" file_bytes = await value.read()
files[key] = (value.filename, file_bytes, value.content_type)
else:
# Приводим всё к строкам, включая 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"""

View File

@@ -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']}")

View File

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

View File

@@ -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
View 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
View 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
View 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
View 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
View 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) более надежный и правильный.

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

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

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

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

View 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_...`.

View 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)
- ✅ Гибкость (можно отключить кеш, если не нужен)
### Когда использовать:
- ✅ Личный кабинет (список незавершенных заявок)
- ✅ Возобновление заполнения формы
- ✅ Быстрая загрузка состояния формы

View 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. Проверить, что всё работает как раньше

View 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}`).

View 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
);
}
```
Но это опционально и не обязательно для веб-формы.

View 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 не возвращает данные - проверить формат параметров

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

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

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

View 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` или свой компонент
**Это оптимальный баланс скорости, контроля и стоимости.**

View 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 сек)

View 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 сек

View 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 секунд

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

View 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) для всех систем

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

View 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
View 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_*`.
Задача: составить **динамический чек-лист** (57 ключевых уточняющих вопросов) + **список документов** для запроса у пользователя, чтобы:
- собрать доказательственную базу для претензии и/или иска;
- минимизировать долги и непонятности (приоритеты, условия загрузки файлов и т.д.);
- предварительно заполнить (prefill) поля формы, если информация уже есть в USER_MESSAGE или RAG_ANSWER.
**Правила работы (строго):**
1. Извлекай информацию ТОЛЬКО из USER_MESSAGE и RAG_ANSWER. Не придумывай фактов. Если чего-то нет — указывай это как missing/needs_confirm.
2. Выбирай 57 уточняющих вопросов (если нужно больше — добавь, но пометь дополнительные с 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 согласно схеме.

View 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 разметки.

View File

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

View File

@@ -49,3 +49,4 @@
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }}>
Выплата поступит на ваш счет в течение нескольких минут Выплата поступит на ваш счет в течение нескольких минут

View File

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

View File

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

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

View File

@@ -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;
@@ -314,6 +348,16 @@ export default function StepWizardPlan({
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);
debugLoggerRef.current?.('wizard', 'info', '✅ SSE соединение открыто', { claim_id: claimId }); debugLoggerRef.current?.('wizard', 'info', '✅ SSE соединение открыто', { claim_id: claimId });
@@ -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>
@@ -511,48 +781,92 @@ export default function StepWizardPlan({
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({
} }

View File

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

View File

@@ -225,3 +225,4 @@ export type WizardPlanSample = typeof wizardPlanSample;
export default wizardPlanSample; export default wizardPlanSample;

View File

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

View File

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

View File

@@ -5,3 +5,4 @@ declare module '*.svg' {
export default content; export default content;
} }