feat: Session persistence with Redis + Draft management fixes

- Implement session management API (/api/v1/session/create, verify, logout)
- Add session restoration from localStorage on page reload
- Fix session_id priority when loading drafts (use current, not old from DB)
- Add unified_id and claim_id to wizard payload sent to n8n
- Add Docker volume for frontend HMR (Hot Module Replacement)
- Add comprehensive session logging for debugging

Components updated:
- backend/app/api/session.py (NEW) - Session management endpoints
- backend/app/main.py - Include session router
- frontend/src/components/form/Step1Phone.tsx v2.0 - Create session after SMS
- frontend/src/pages/ClaimForm.tsx v3.8 - Session restoration & priority fix
- frontend/src/components/form/StepWizardPlan.tsx v1.4 - Add unified_id/claim_id
- docker-compose.yml - Add frontend volume for live reload

Session flow:
1. User verifies phone -> session created in Redis (24h TTL)
2. session_token saved to localStorage
3. Page reload -> session restored automatically
4. Draft selected -> current session_id used (not old from DB)
5. Wizard submit -> unified_id, claim_id, session_id sent to n8n
6. Logout -> session removed from Redis & localStorage

Fixes:
- Session token not persisting after page reload
- unified_id missing in n8n webhook payload
- Old session_id from draft overwriting current session
- Frontend changes requiring container rebuild
This commit is contained in:
AI Assistant
2025-11-20 18:31:42 +03:00
parent 4c8fda5f55
commit 3621ae6021
25 changed files with 3120 additions and 181 deletions

View File

@@ -52,9 +52,9 @@ export default function DebugPanel({ events, formData }: Props) {
}}
styles={{
header: {
background: '#252526',
color: '#fff',
borderBottom: '1px solid #333'
background: '#252526',
color: '#fff',
borderBottom: '1px solid #333'
},
body: {
padding: 12

View File

@@ -17,6 +17,8 @@ export default function Step1Phone({
setIsPhoneVerified,
addDebugEvent
}: Props) {
// 🆕 VERSION CHECK: 2025-11-20 12:40 - session_id fix
console.log('📱 Step1Phone v2.0 - 2025-11-20 14:40 - Session creation with debug logs');
const [form] = Form.useForm();
const [codeSent, setCodeSent] = useState(false);
const [loading, setLoading] = useState(false);
@@ -109,37 +111,120 @@ export default function Step1Phone({
}
console.log('🔥 N8N CRM Response (after array check):', crmResult);
console.log('🔥 N8N CRM Response FULL:', JSON.stringify(crmResult, null, 2));
if (crmResponse.ok && crmResult.success) {
// n8n возвращает: {success: true, result: {claim_id, contact_id, ...}}
const result = crmResult.result || crmResult;
console.log('🔥 Extracted result:', result);
console.log('🔥 Saving to formData:', {
phone,
contact_id: result.contact_id,
claim_id: result.claim_id,
unified_id: result.unified_id, // ← Добавляем в лог
is_new_contact: result.is_new_contact
});
console.log('🔥 result.unified_id:', result.unified_id);
console.log('🔥 typeof result.unified_id:', typeof result.unified_id);
console.log('🔥 result keys:', Object.keys(result));
addDebugEvent?.('crm', 'success', `✅ Контакт создан/найден в CRM`, result);
// ✅ ВАЖНО: Проверяем наличие unified_id
if (!result.unified_id) {
console.error('❌ unified_id отсутствует в ответе n8n!');
console.error('❌ Полный ответ result:', result);
console.error('❌ Полный ответ crmResult:', crmResult);
message.warning('⚠️ unified_id не получен от n8n, черновики могут не отображаться');
} else {
console.log('✅ unified_id получен:', result.unified_id);
}
// Сохраняем данные из CRM в форму
updateFormData({
// ✅ Извлекаем session_id от n8n (если есть)
const session_id_from_n8n = result.session;
console.log('🔍 Проверка session_id от n8n:');
console.log('🔍 result.session:', result.session);
console.log('🔍 session_id_from_n8n:', session_id_from_n8n);
console.log('🔍 formData.session_id (текущий):', formData.session_id);
if (session_id_from_n8n) {
console.log('✅ session_id получен от n8n:', session_id_from_n8n);
} else {
console.warn('⚠️ session_id не найден в ответе n8n, используем текущий:', formData.session_id);
}
const finalSessionId = session_id_from_n8n || formData.session_id;
console.log('🔍 finalSessionId (будет сохранён):', finalSessionId);
const dataToSave = {
phone,
smsCode: code,
contact_id: result.contact_id,
unified_id: result.unified_id, // ✅ Unified ID из PostgreSQL (получаем от n8n)
claim_id: result.claim_id,
session_id: finalSessionId, // ✅ Используем session_id от n8n, если есть
// claim_id убран - используем только session_id на этих этапах
is_new_contact: result.is_new_contact
});
};
console.log('🔥 ========== SAVING TO FORMDATA ==========');
console.log('🔥 Saving to formData:', JSON.stringify(dataToSave, null, 2));
console.log('🔥 dataToSave.unified_id:', dataToSave.unified_id);
console.log('🔥 dataToSave.session_id:', dataToSave.session_id);
console.log('🔥 =========================================');
addDebugEvent?.('crm', 'success', `✅ Контакт создан/найден в CRM`, result);
// Сохраняем данные из CRM в форму
updateFormData(dataToSave);
message.success(result.is_new_contact ? 'Контакт создан!' : 'Контакт найден!');
// ✅ Устанавливаем isPhoneVerified = true после успешной верификации
setIsPhoneVerified(true);
// 🔑 Создаём сессию в Redis для живучести (24 часа)
try {
console.log('🔑 Создаём сессию в Redis:', {
session_token: finalSessionId,
unified_id: result.unified_id,
phone: phone,
contact_id: result.contact_id
});
const sessionResponse = await fetch('/api/v1/session/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_token: finalSessionId,
unified_id: result.unified_id,
phone: phone,
contact_id: result.contact_id,
ttl_hours: 24
})
});
console.log('🔑 Session create response status:', sessionResponse.status);
if (sessionResponse.ok) {
const sessionData = await sessionResponse.json();
console.log('🔑 Session create response data:', sessionData);
// Сохраняем session_token в localStorage для последующих визитов
localStorage.setItem('session_token', finalSessionId);
console.log('✅ Сессия создана в Redis, session_token сохранён в localStorage:', finalSessionId);
console.log('✅ Проверка: localStorage.getItem("session_token"):', localStorage.getItem('session_token'));
addDebugEvent?.('session', 'success', '✅ Сессия создана (TTL 24h)');
} else {
const errorText = await sessionResponse.text();
console.warn('⚠️ Не удалось создать сессию в Redis:', sessionResponse.status, errorText);
}
} catch (sessionError) {
console.error('❌ Ошибка создания сессии:', sessionError);
// Не блокируем дальнейшую работу
}
// ✅ Передаем unified_id напрямую в onNext для проверки черновиков
// Это нужно, потому что formData может еще не обновиться
onNext(result.unified_id);
const unifiedIdToPass = result.unified_id;
console.log('🔥 ============================================');
console.log('🔥 Передаём unified_id в onNext:', unifiedIdToPass);
console.log('🔥 typeof unifiedIdToPass:', typeof unifiedIdToPass);
console.log('🔥 Вызываем onNext с unified_id:', unifiedIdToPass);
console.log('🔥 ============================================');
onNext(unifiedIdToPass);
} else {
addDebugEvent?.('crm', 'error', '❌ Ошибка создания контакта в CRM', crmResult);
message.error('Ошибка создания контакта в CRM');

View File

@@ -209,7 +209,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
body: JSON.stringify({
claim_id: formData.claim_id, // Передаём claim_id для создания записи
policy_number: values.voucher,
session_id: sessionStorage.getItem('session_id') || 'unknown'
session_id: formData.session_id || 'unknown'
}),
});
@@ -345,7 +345,7 @@ export default function Step1Policy({ formData, updateFormData, onNext, addDebug
uploadFormData.append('file_type', 'policy_scan');
uploadFormData.append('filename', pdfFile.name); // PDF имя
uploadFormData.append('voucher', values.voucher);
uploadFormData.append('session_id', sessionStorage.getItem('session_id') || 'unknown');
uploadFormData.append('session_id', formData.session_id || 'unknown');
uploadFormData.append('upload_timestamp', new Date().toISOString());
uploadFormData.append('file', pdfFile); // PDF файл!

View File

@@ -302,7 +302,7 @@ export default function Step2Details({ formData, updateFormData, onNext, onPrev,
uploadFormData.append('file_type', currentDocConfig.file_type);
uploadFormData.append('filename', currentFile.name);
uploadFormData.append('voucher', formData.voucher || '');
uploadFormData.append('session_id', sessionStorage.getItem('session_id') || 'unknown');
uploadFormData.append('session_id', formData.session_id || 'unknown');
uploadFormData.append('upload_timestamp', new Date().toISOString());
uploadFormData.append('file', currentFile);

View File

@@ -53,20 +53,14 @@ export default function StepDescription({
message.error('Не найден session_id. Попробуйте обновить страницу.');
return;
}
if (!formData.claim_id) {
message.error('Не удалось определить номер обращения. Вернитесь на шаг с телефоном.');
return;
}
setSubmitting(true);
if (useMockWizard && wizardPlanSample?.wizard_plan) {
const mockPrefill = buildPrefillMap(wizardPlanSample.answers_prefill);
const mockClaimId = wizardPlanSample.claim_id || formData.claim_id;
updateFormData({
problemDescription: safeDescription,
claim_id: mockClaimId,
wizardPlan: wizardPlanSample.wizard_plan,
wizardPlanStatus: 'ready',
wizardPrefill: mockPrefill,
@@ -85,7 +79,6 @@ export default function StepDescription({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_id: formData.session_id,
claim_id: formData.claim_id,
phone: formData.phone,
email: formData.email,
problem_description: safeDescription,

View File

@@ -32,8 +32,9 @@ interface Draft {
}
interface Props {
phone: string;
phone?: string;
session_id?: string;
unified_id?: string; // ✅ Добавляем unified_id
onSelectDraft: (claimId: string) => void;
onNewClaim: () => void;
}
@@ -41,6 +42,7 @@ interface Props {
export default function StepDraftSelection({
phone,
session_id,
unified_id, // ✅ Добавляем unified_id
onSelectDraft,
onNewClaim,
}: Props) {
@@ -52,18 +54,29 @@ export default function StepDraftSelection({
try {
setLoading(true);
const params = new URLSearchParams();
if (session_id) {
params.append('session_id', session_id);
// ✅ Приоритет: unified_id > phone > session_id
if (unified_id) {
params.append('unified_id', unified_id);
console.log('🔍 StepDraftSelection: загружаем черновики по unified_id:', unified_id);
} else if (phone) {
params.append('phone', phone);
console.log('🔍 StepDraftSelection: загружаем черновики по phone:', phone);
} else if (session_id) {
params.append('session_id', session_id);
console.log('🔍 StepDraftSelection: загружаем черновики по session_id:', session_id);
}
const response = await fetch(`/api/v1/claims/drafts/list?${params.toString()}`);
const url = `/api/v1/claims/drafts/list?${params.toString()}`;
console.log('🔍 StepDraftSelection: запрос:', url);
const response = await fetch(url);
if (!response.ok) {
throw new Error('Не удалось загрузить черновики');
}
const data = await response.json();
console.log('🔍 StepDraftSelection: ответ API:', data);
console.log('🔍 StepDraftSelection: количество черновиков:', data.count);
setDrafts(data.drafts || []);
} catch (error) {
console.error('Ошибка загрузки черновиков:', error);
@@ -75,7 +88,7 @@ export default function StepDraftSelection({
useEffect(() => {
loadDrafts();
}, [phone, session_id]);
}, [phone, session_id, unified_id]); // ✅ Добавляем unified_id в зависимости
const handleDelete = async (claimId: string) => {
try {
@@ -119,11 +132,11 @@ export default function StepDraftSelection({
>
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Title level={3} style={{ marginBottom: 8 }}>
Продолжить заполнение или создать новую заявку?
<Title level={2} style={{ marginBottom: 8, color: '#1890ff' }}>
📋 Ваши черновики заявок
</Title>
<Paragraph type="secondary">
У вас есть незавершенные черновики. Вы можете продолжить заполнение или создать новую заявку.
<Paragraph type="secondary" style={{ fontSize: 14, marginBottom: 16 }}>
Выберите черновик, чтобы продолжить заполнение, или создайте новую заявку.
</Paragraph>
</div>
@@ -157,7 +170,13 @@ export default function StepDraftSelection({
<Button
key="continue"
type="primary"
onClick={() => onSelectDraft(draft.claim_id!)}
onClick={() => {
console.log('🔍 Выбран черновик:', draft.claim_id, 'id:', draft.id);
// Используем id (UUID) если claim_id отсутствует
const draftId = draft.claim_id || draft.id;
console.log('🔍 Загружаем черновик с ID:', draftId);
onSelectDraft(draftId);
}}
icon={<FileTextOutlined />}
>
Продолжить

View File

@@ -112,6 +112,7 @@ export default function StepWizardPlan({
onPrev,
addDebugEvent,
}: Props) {
console.log('🔥 StepWizardPlan v1.4 - 2025-11-20 15:00 - Add unified_id and claim_id to wizard payload');
const [form] = Form.useForm();
const eventSourceRef = useRef<EventSource | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -146,6 +147,36 @@ export default function StepWizardPlan({
debugLoggerRef.current = addDebugEvent;
}, [addDebugEvent]);
// ✅ Автосохранение прогресса заполнения (debounce 3 секунды)
useEffect(() => {
if (!formData.claim_id || !formValues) return;
const timeoutId = setTimeout(() => {
const answers = form.getFieldsValue(true);
// Сохраняем только если есть хоть какие-то ответы
const hasAnswers = Object.keys(answers).some(key => answers[key] !== undefined && answers[key] !== '');
if (hasAnswers) {
console.log('💾 Автосохранение прогресса:', { claim_id: formData.claim_id, answersCount: Object.keys(answers).length });
// Обновляем formData с текущими ответами
updateFormData({
wizardAnswers: answers,
wizardUploads: {
documents: questionFileBlocks,
custom: customFileBlocks,
},
wizardSkippedDocuments: Array.from(skippedDocuments),
});
addDebugEvent?.('wizard', 'info', '💾 Автосохранение прогресса');
}
}, 3000); // 3 секунды debounce
return () => clearTimeout(timeoutId);
}, [formValues, formData.claim_id]); // Зависимость от formValues, но БЕЗ questionFileBlocks/customFileBlocks/skippedDocuments (они обновляются отдельно)
const questions: WizardQuestion[] = useMemo(() => plan?.questions || [], [plan]);
const documents: WizardDocument[] = plan?.documents || [];
@@ -339,19 +370,19 @@ export default function StepWizardPlan({
}, [formValues, plan, questions, documentGroups, questionFileBlocks, handleDocumentBlocksChange, skippedDocuments]);
useEffect(() => {
if (!isWaiting || !formData.claim_id || plan) {
if (!isWaiting || !formData.session_id || plan) {
return;
}
const claimId = formData.claim_id;
const source = new EventSource(`/events/${claimId}`);
const sessionId = formData.session_id;
const source = new EventSource(`/events/${sessionId}`);
eventSourceRef.current = source;
debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { claim_id: claimId });
debugLoggerRef.current?.('wizard', 'info', '🔌 Подключаемся к SSE для плана вопросов', { session_id: sessionId });
// Таймаут: если план не пришёл за 2 минуты (RAG может работать долго), показываем ошибку
timeoutRef.current = setTimeout(() => {
setConnectionError('План вопросов не получен. Проверьте, что n8n обработал описание проблемы.');
debugLoggerRef.current?.('wizard', 'error', '⏱️ Таймаут ожидания плана вопросов (2 минуты)', { claim_id: claimId });
debugLoggerRef.current?.('wizard', 'error', '⏱️ Таймаут ожидания плана вопросов (2 минуты)', { session_id: sessionId });
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
@@ -360,7 +391,7 @@ export default function StepWizardPlan({
source.onopen = () => {
setConnectionError(null);
debugLoggerRef.current?.('wizard', 'info', '✅ SSE соединение открыто', { claim_id: claimId });
debugLoggerRef.current?.('wizard', 'info', '✅ SSE соединение открыто', { session_id: sessionId });
};
source.onerror = (error) => {
@@ -368,7 +399,7 @@ export default function StepWizardPlan({
setConnectionError('Не удалось получить ответ от AI. Попробуйте ещё раз.');
source.close();
eventSourceRef.current = null;
debugLoggerRef.current?.('wizard', 'error', '❌ SSE ошибка (wizard)', { claim_id: claimId });
debugLoggerRef.current?.('wizard', 'error', '❌ SSE ошибка (wizard)', { session_id: sessionId });
};
const extractWizardPayload = (incoming: any): any => {
@@ -403,7 +434,7 @@ export default function StepWizardPlan({
// Логируем все события для отладки
debugLoggerRef.current?.('wizard', 'info', '📨 Получено SSE событие', {
claim_id: claimId,
session_id: sessionId,
event_type: eventType,
has_wizard_plan: Boolean(extractWizardPayload(payload)),
payload_keys: Object.keys(payload),
@@ -419,7 +450,7 @@ export default function StepWizardPlan({
const coverageReport = wizardPayload?.coverage_report;
debugLoggerRef.current?.('wizard', 'success', '✨ Получен план вопросов', {
claim_id: claimId,
session_id: sessionId,
questions: wizardPlan?.questions?.length || 0,
});
@@ -459,11 +490,11 @@ export default function StepWizardPlan({
eventSourceRef.current = null;
}
};
}, [isWaiting, formData.claim_id, plan, updateFormData]);
}, [isWaiting, formData.session_id, plan, updateFormData]);
const handleRefreshPlan = () => {
if (!formData.claim_id) {
message.error('Не найден claim_id для подписки на события.');
if (!formData.session_id) {
message.error('Не найден session_id для подписки на события.');
return;
}
setIsWaiting(true);
@@ -561,7 +592,7 @@ export default function StepWizardPlan({
try {
setSubmitting(true);
addDebugEvent?.('wizard', 'info', '📤 Отправляем данные визарда в n8n', {
claim_id: formData.claim_id,
session_id: formData.session_id,
});
const formPayload = new FormData();
@@ -570,6 +601,8 @@ export default function StepWizardPlan({
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);
// Добавляем unified_id и claim_id (если есть)
if (formData.unified_id) formPayload.append('unified_id', formData.unified_id);
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));
@@ -686,6 +719,15 @@ export default function StepWizardPlan({
});
});
// Логируем ключевые поля перед отправкой
console.log('📤 Отправка в n8n:', {
session_id: formData.session_id,
unified_id: formData.unified_id,
claim_id: formData.claim_id,
contact_id: formData.contact_id,
phone: formData.phone,
});
const response = await fetch('/api/v1/claims/wizard', {
method: 'POST',
body: formPayload,
@@ -978,7 +1020,14 @@ export default function StepWizardPlan({
</Card>
);
const renderQuestions = () => (
const renderQuestions = () => {
console.log('🔍 StepWizardPlan renderQuestions:', {
questionsCount: questions.length,
documentsCount: documents.length,
questions: questions.map(q => ({ name: q.name, label: q.label, input_type: q.input_type, required: q.required }))
});
return (
<>
<Card
size="small"
@@ -1001,21 +1050,21 @@ export default function StepWizardPlan({
initialValues={{ ...prefillMap, ...formData.wizardAnswers }}
>
{questions.map((question) => {
// Для условных полей используем dependencies для отслеживания изменений
const dependencies = question.ask_if ? [question.ask_if.field] : undefined;
// Для условных полей используем shouldUpdate для отслеживания изменений
const hasCondition = !!question.ask_if;
return (
<Form.Item
key={question.name}
dependencies={dependencies}
shouldUpdate={dependencies ? (prev, curr) => {
shouldUpdate={hasCondition ? (prev, curr) => {
// Обновляем только если изменилось значение поля, от которого зависит вопрос
return prev[question.ask_if!.field] !== curr[question.ask_if!.field];
} : undefined}
} : true} // ✅ Для безусловных полей shouldUpdate=true, чтобы render function работала
>
{() => {
const values = form.getFieldsValue(true);
if (!evaluateCondition(question.ask_if, values)) {
console.log(`⏭️ Question ${question.name} skipped: condition not met`, question.ask_if, values);
return null;
}
const questionDocs = documentGroups[question.name] || [];
@@ -1045,9 +1094,12 @@ export default function StepWizardPlan({
// (даже если вопрос не связан с documentGroups)
// Загрузка файлов уже реализована через блоки документов (documents)
if (isDocumentUploadQuestion && documents.length > 0) {
console.log(`🚫 Question ${question.name} hidden: isDocumentUploadQuestion=true, documents.length=${documents.length}`);
return null;
}
console.log(`✅ Question ${question.name} will render:`, { input_type: question.input_type, label: question.label, required: question.required });
return (
<>
<Form.Item
@@ -1094,14 +1146,15 @@ export default function StepWizardPlan({
</Form>
{renderCustomUploads()}
</>
);
);
};
if (!formData.claim_id) {
if (!formData.session_id) {
return (
<Result
status="warning"
title="Нет claim_id"
subTitle="Не удалось определить идентификатор заявки. Вернитесь на предыдущий шаг и попробуйте снова."
title="Нет session_id"
subTitle="Не удалось определить идентификатор сессии. Вернитесь на предыдущий шаг и попробуйте снова."
extra={<Button onClick={onPrev}>Вернуться</Button>}
/>
);

View File

@@ -1,5 +1,5 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { Steps, Card, message, Row, Col } from 'antd';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { Steps, Card, message, Row, Col, Space } from 'antd';
import Step1Phone from '../components/form/Step1Phone';
import StepDescription from '../components/form/StepDescription';
import Step1Policy from '../components/form/Step1Policy';
@@ -68,21 +68,16 @@ export default function ClaimForm() {
// ✅ claim_id будет создан n8n в Step1Phone после SMS верификации
// Не генерируем его локально!
// Генерируем session_id и сохраняем в sessionStorage
const [sessionId] = useState(() => {
let sid = sessionStorage.getItem('session_id');
if (!sid) {
sid = `sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
sessionStorage.setItem('session_id', sid);
}
return sid;
});
// session_id будет получен от n8n при создании контакта
// Используем useRef чтобы sessionId не вызывал перерендер и был стабильным
const sessionIdRef = useRef(`sess-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
const [currentStep, setCurrentStep] = useState(0);
const [sessionRestored, setSessionRestored] = useState(false); // Флаг: пытались восстановить сессию
const [formData, setFormData] = useState<FormData>({
voucher: '',
claim_id: undefined, // ✅ Будет заполнен n8n в Step1Phone
session_id: sessionId,
session_id: sessionIdRef.current,
paymentMethod: 'sbp',
});
const [isPhoneVerified, setIsPhoneVerified] = useState(false);
@@ -94,9 +89,104 @@ export default function ClaimForm() {
useEffect(() => {
// 🔥 VERSION CHECK: Если видишь это в консоли - фронт обновился!
console.log('🔥 ClaimForm v2.0 - claim_id НЕ генерируется на фронте!');
console.log('🔥 ClaimForm v3.8 - 2025-11-20 15:10 - Fix session_id priority in loadDraft');
}, []);
// ✅ Восстановление сессии при загрузке страницы
useEffect(() => {
const restoreSession = async () => {
console.log('🔑 🔑 🔑 НАЧАЛО ВОССТАНОВЛЕНИЯ СЕССИИ 🔑 🔑 🔑');
console.log('🔑 Все ключи в localStorage:', Object.keys(localStorage));
console.log('🔑 Значения всех ключей:', JSON.stringify(localStorage));
const savedSessionToken = localStorage.getItem('session_token');
if (!savedSessionToken) {
console.log('❌ Session token NOT found in localStorage');
setSessionRestored(true);
return;
}
console.log('✅ Found session_token in localStorage, verifying:', savedSessionToken);
addDebugEvent('session', 'info', '🔑 Проверка сохранённой сессии');
try {
const response = await fetch('/api/v1/session/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: savedSessionToken })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
console.log('🔑 Session verify response:', data);
if (data.success && data.valid) {
// Сессия валидна! Восстанавливаем состояние
console.log('✅ Session valid! Restoring user data:', {
unified_id: data.unified_id,
phone: data.phone,
expires_in: data.expires_in_seconds
});
// Обновляем formData с данными сессии
updateFormData({
unified_id: data.unified_id,
phone: data.phone,
contact_id: data.contact_id,
session_id: savedSessionToken
});
// Устанавливаем session_id в ref
sessionIdRef.current = savedSessionToken;
// Помечаем телефон как верифицированный
setIsPhoneVerified(true);
// Проверяем черновики
const hasDraftsResult = await checkDrafts(data.unified_id, data.phone, savedSessionToken);
if (hasDraftsResult) {
// Есть черновики - показываем список
setShowDraftSelection(true);
setHasDrafts(true);
// Переходим к шагу выбора черновика
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setCurrentStep(0);
});
});
message.success(`Добро пожаловать! Сессия восстановлена (${data.phone})`);
addDebugEvent('session', 'success', '✅ Сессия восстановлена, найдены черновики');
} else {
// Нет черновиков - переходим к описанию
setCurrentStep(1);
message.success(`Добро пожаловать! Сессия восстановлена (${data.phone})`);
addDebugEvent('session', 'success', '✅ Сессия восстановлена');
}
} else {
// Сессия невалидна - удаляем из localStorage
console.log('❌ Session invalid or expired, removing from localStorage');
localStorage.removeItem('session_token');
addDebugEvent('session', 'warning', '⚠️ Сессия истекла');
}
} catch (error) {
console.error('❌ Error verifying session:', error);
localStorage.removeItem('session_token');
addDebugEvent('session', 'error', '❌ Ошибка проверки сессии');
} finally {
setSessionRestored(true);
}
};
restoreSession();
}, []); // Запускаем только при загрузке
// Получаем IP клиента один раз при монтировании
useEffect(() => {
const fetchClientIp = async () => {
@@ -157,57 +247,142 @@ export default function ClaimForm() {
// Загрузка черновика
const loadDraft = useCallback(async (claimId: string) => {
try {
const response = await fetch(`/api/v1/claims/drafts/${claimId}`);
console.log('🔍 Загрузка черновика с ID:', claimId);
const url = `/api/v1/claims/drafts/${claimId}`;
console.log('🔍 URL запроса:', url);
const response = await fetch(url);
console.log('🔍 Статус ответа:', response.status, response.statusText);
if (!response.ok) {
throw new Error('Не удалось загрузить черновик');
const errorText = await response.text();
console.error('❌ Ошибка загрузки черновика:', response.status, errorText);
throw new Error(`Не удалось загрузить черновик: ${response.status} ${errorText}`);
}
const data = await response.json();
console.log('🔍 Данные черновика загружены:', data);
const claim = data.claim;
const payload = claim.payload || {};
// ✅ Для telegram черновиков данные могут быть в payload.body
const body = payload.body || {};
const isTelegramFormat = !!payload.body;
console.log('🔍 Claim объект:', claim);
console.log('🔍 claim.claim_id:', claim.claim_id);
console.log('🔍 claim.id:', claim.id);
console.log('🔍 Payload черновика:', payload);
console.log('🔍 payload.body:', body);
console.log('🔍 Формат:', isTelegramFormat ? 'telegram (body)' : 'web_form (прямой)');
// ✅ Извлекаем данные из body (telegram) или напрямую из payload (web_form)
const wizardPlanRaw = body.wizard_plan || payload.wizard_plan;
const answersRaw = body.answers || payload.answers;
const problemDescription = body.problem_description || payload.problem_description || body.description || payload.description;
// ✅ Парсим wizard_plan и answers, если они строки (JSON)
let wizardPlan = wizardPlanRaw;
if (typeof wizardPlanRaw === 'string') {
try {
wizardPlan = JSON.parse(wizardPlanRaw);
} catch (e) {
console.warn('⚠️ Не удалось распарсить wizard_plan:', e);
}
}
let answers = answersRaw;
if (typeof answersRaw === 'string') {
try {
answers = JSON.parse(answersRaw);
} catch (e) {
console.warn('⚠️ Не удалось распарсить answers:', e);
}
}
console.log('🔍 problem_description:', problemDescription ? 'есть' : 'нет');
console.log('🔍 wizard_plan:', wizardPlan ? 'есть' : 'нет');
console.log('🔍 answers:', answers ? 'есть' : 'нет');
console.log('🔍 Все ключи payload:', Object.keys(payload));
if (isTelegramFormat) {
console.log('🔍 Все ключи body:', Object.keys(body));
}
// ✅ Извлекаем claim_id из разных возможных мест
const finalClaimId = claim.claim_id || payload.claim_id || body.claim_id || claim.id || formData.claim_id || claimId;
console.log('🔍 Извлечённый claim_id:', finalClaimId);
// Восстанавливаем данные формы из черновика
console.log('🔄 Загрузка черновика: session_id из черновика:', claim.session_token);
console.log('🔄 Загрузка черновика: текущий sessionIdRef.current:', sessionIdRef.current);
console.log('🔄 Загрузка черновика: текущий formData.session_id:', formData.session_id);
const actualSessionId = sessionIdRef.current || formData.session_id;
console.log('🔄 Загрузка черновика: ИСПОЛЬЗУЕМ session_id:', actualSessionId);
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) => {
claim_id: finalClaimId, // ✅ Используем извлечённый claim_id
session_id: actualSessionId, // ✅ Используем ТЕКУЩИЙ session_id, а не старый из черновика
phone: body.phone || payload.phone || formData.phone,
email: body.email || payload.email || formData.email,
problemDescription: problemDescription || formData.problemDescription,
wizardPlan: wizardPlan || formData.wizardPlan,
wizardPlanStatus: wizardPlan ? (answers ? 'answered' : 'ready') : 'pending', // ✅ Устанавливаем статус
wizardAnswers: answers || formData.wizardAnswers,
wizardPrefill: (body.answers_prefill || payload.answers_prefill) ?
(body.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,
wizardPrefillArray: body.answers_prefill || payload.answers_prefill || formData.wizardPrefillArray,
wizardCoverageReport: body.coverage_report || payload.coverage_report || formData.wizardCoverageReport,
wizardUploads: {
documents: payload.documents_meta ? {} : formData.wizardUploads?.documents,
documents: (body.documents_meta || 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,
wizardSkippedDocuments: body.wizard_skipped_documents || payload.wizard_skipped_documents || formData.wizardSkippedDocuments,
eventType: body.event_type || payload.event_type || formData.eventType,
contact_id: body.contact_id || payload.contact_id || formData.contact_id,
project_id: body.project_id || payload.project_id || formData.project_id,
unified_id: formData.unified_id, // ✅ Сохраняем unified_id
});
setSelectedDraftId(claimId);
setSelectedDraftId(finalClaimId);
setShowDraftSelection(false);
// Переходим к шагу с описанием, если оно есть, иначе к шагу с рекомендациями
if (payload.problem_description) {
// Если есть описание, переходим к шагу с рекомендациями
setCurrentStep(2); // StepWizardPlan
// ✅ Определяем шаг для перехода на основе данных черновика
// Приоритет: если есть wizard_plan → переходим к визарду (даже если нет problem_description)
// После выбора черновика showDraftSelection = false, поэтому:
// - Шаг 0 = Step1Phone (но мы его пропускаем, т.к. телефон уже верифицирован)
// - Шаг 1 = StepDescription
// - Шаг 2 = StepWizardPlan
let targetStep = 1; // По умолчанию - описание (шаг 1)
if (wizardPlan) {
// ✅ Если есть wizard_plan - переходим к визарду (шаг 2)
// Пользователь уже описывал проблему, и есть план вопросов
targetStep = 2;
console.log('✅ Переходим к StepWizardPlan (шаг 2) - есть wizard_plan');
console.log('✅ answers в черновике:', answers ? 'есть (показываем заполненную форму)' : 'нет (показываем пустую форму)');
} else if (problemDescription) {
// Если есть описание, но нет плана - переходим к визарду (шаг 2), чтобы получить план
targetStep = 2;
console.log('✅ Переходим к StepWizardPlan (шаг 2) - есть описание, план будет получен через SSE');
} else {
// Если нет описания, переходим к шагу с описанием
setCurrentStep(1); // StepDescription
// Если нет ничего - переходим к описанию (шаг 1)
targetStep = 1;
console.log('✅ Переходим к StepDescription (шаг 1) - нет описания и плана');
}
console.log('🔍 Устанавливаем currentStep:', targetStep);
// ✅ Устанавливаем isPhoneVerified = true, чтобы пропустить шаг телефона
setIsPhoneVerified(true);
setCurrentStep(targetStep);
} catch (error) {
console.error('Ошибка загрузки черновика:', error);
message.error('Не удалось загрузить черновик');
}
}, [formData, sessionId, updateFormData]);
}, [formData, updateFormData]);
// Обработчик выбора черновика
const handleSelectDraft = useCallback((claimId: string) => {
@@ -240,6 +415,7 @@ export default function ClaimForm() {
const data = await response.json();
console.log('🔍 Ответ API черновиков:', data);
console.log('🔍 Debug info от backend:', data.debug);
const count = data.count || 0;
console.log('🔍 Количество черновиков:', count);
@@ -254,8 +430,14 @@ export default function ClaimForm() {
// Обработчик создания новой заявки
const handleNewClaim = useCallback(() => {
console.log('🆕 Начинаем новое обращение');
console.log('🆕 Текущий currentStep:', currentStep);
console.log('🆕 isPhoneVerified:', isPhoneVerified);
setShowDraftSelection(false);
setSelectedDraftId(null);
setHasDrafts(false); // ✅ Сбрасываем флаг наличия черновиков
// Очищаем данные формы, кроме телефона и session_id
updateFormData({
claim_id: undefined,
@@ -269,9 +451,16 @@ export default function ClaimForm() {
wizardSkippedDocuments: undefined,
eventType: undefined,
});
// Переходим к шагу с описанием
setCurrentStep(1);
}, [updateFormData]);
console.log('🆕 Переходим к шагу описания проблемы (пропускаем Phone и DraftSelection)');
// ✅ Переходим к шагу описания проблемы
// После сброса флагов черновиков, steps будут:
// Шаг 0 - Phone (уже верифицирован, но в массиве есть)
// Шаг 1 - Description (сюда переходим)
// Шаг 2 - WizardPlan
setCurrentStep(1); // ✅ Переходим к описанию (индекс 1)
}, [updateFormData, currentStep, isPhoneVerified]);
const handleSubmit = useCallback(async () => {
try {
@@ -280,7 +469,7 @@ export default function ClaimForm() {
const payload = {
stage: 'final',
form_id: 'ticket_form',
session_id: formData.session_id ?? sessionId,
session_id: formData.session_id ?? sessionIdRef.current,
client_ip: formData.clientIp,
sms_code: formData.smsCode,
@@ -346,21 +535,24 @@ export default function ClaimForm() {
addDebugEvent('form', 'error', '❌ Ошибка соединения', { error: String(error) });
console.error(error);
}
}, [formData, sessionId, addDebugEvent]);
}, [formData, addDebugEvent]);
// Динамически генерируем шаги на основе выбранного eventType
const steps = useMemo(() => {
const stepsArray: any[] = [];
// Шаг 0: Выбор черновика (показывается только если есть черновики и телефон верифицирован)
if (showDraftSelection && isPhoneVerified && !selectedDraftId && hasDrafts) {
// Шаг 0: Выбор черновика (показывается только если есть черновики)
// ✅ unified_id уже означает, что телефон верифицирован
// Показываем шаг, если showDraftSelection=true ИЛИ если есть unified_id и hasDrafts
if ((showDraftSelection || (formData.unified_id && hasDrafts)) && !selectedDraftId) {
stepsArray.push({
title: 'Черновики',
description: 'Выбор заявки',
content: (
<StepDraftSelection
phone={formData.phone || ''}
session_id={sessionId}
session_id={sessionIdRef.current}
unified_id={formData.unified_id} // ✅ Передаём unified_id
onSelectDraft={handleSelectDraft}
onNewClaim={handleNewClaim}
/>
@@ -374,13 +566,15 @@ export default function ClaimForm() {
description: 'Подтверждение по SMS',
content: (
<Step1Phone
formData={{ ...formData, session_id: sessionId }} // ✅ claim_id будет создан n8n
formData={{ ...formData, session_id: formData.session_id || sessionIdRef.current }} // ✅ Используем session_id из formData (от n8n) или временный
updateFormData={(data: any) => {
updateFormData(data);
// После верификации телефона проверяем черновики
if (data.phone && isPhoneVerified && !selectedDraftId && !showDraftSelection) {
setShowDraftSelection(true);
// ✅ Если n8n вернул session_id, обновляем ref
if (data.session_id && data.session_id !== sessionIdRef.current) {
console.log('🔄 Обновляем sessionIdRef на значение от n8n:', data.session_id);
sessionIdRef.current = data.session_id;
}
// ❌ Убрано: проверка черновиков здесь избыточна, т.к. она уже есть в onNext
}}
onNext={async (unified_id?: string) => {
console.log('🔥 onNext вызван с unified_id:', unified_id);
@@ -393,33 +587,59 @@ export default function ClaimForm() {
const finalUnifiedId = unified_id || formData.unified_id;
console.log('🔥 finalUnifiedId:', finalUnifiedId);
if (formData.phone && isPhoneVerified && !selectedDraftId) {
// ✅ Если передан unified_id, значит телефон уже верифицирован (даже если isPhoneVerified ещё false)
// Проверяем черновики, если есть unified_id или телефон верифицирован
const shouldCheckDrafts = finalUnifiedId || (formData.phone && isPhoneVerified);
if (shouldCheckDrafts && !selectedDraftId) {
console.log('🔍 Проверка черновиков с unified_id:', finalUnifiedId, 'phone:', formData.phone);
const hasDraftsResult = await checkDrafts(finalUnifiedId, formData.phone, sessionId);
const hasDraftsResult = await checkDrafts(finalUnifiedId, formData.phone, sessionIdRef.current);
console.log('🔍 Результат checkDrafts:', hasDraftsResult);
if (hasDraftsResult) {
console.log('✅ Есть черновики, переходим к шагу 0');
setCurrentStep(0); // Переходим к шагу выбора черновика
// ✅ ВАЖНО: Сначала устанавливаем флаги, потом переходим на шаг 0
setShowDraftSelection(true);
setHasDrafts(true);
// ✅ Ждём следующего тика, чтобы useMemo пересчитался с новыми флагами
// Используем requestAnimationFrame для гарантии, что React обновил состояние
requestAnimationFrame(() => {
requestAnimationFrame(() => {
console.log('🔄 Переходим на шаг 0 после установки флагов');
setCurrentStep(0); // Переходим к шагу выбора черновика
});
});
console.log('🛑 Остановка выполнения onNext - есть черновики');
console.log('🛑 RETURN - функция должна остановиться здесь');
return; // ✅ ВАЖНО: Не идём дальше, если есть черновики
} else {
console.log('❌ Нет черновиков, идем дальше');
nextStep(); // Нет черновиков, идем дальше
// Нет черновиков - идём дальше
nextStep();
return;
}
} else {
console.log('⚠️ Условие не выполнено, идем дальше');
console.log('⚠️ Условие не выполнено для проверки черновиков:', {
shouldCheckDrafts,
selectedDraftId,
finalUnifiedId,
phone: formData.phone,
isPhoneVerified
});
// Условие не выполнено - идём дальше
nextStep();
return;
}
// ❌ ЭТОТ КОД НЕ ДОЛЖЕН ВЫПОЛНЯТЬСЯ, если есть return выше
console.error('❌❌❌ КРИТИЧЕСКАЯ ОШИБКА: nextStep() вызван после return!');
nextStep();
}}
onPrev={prevStep}
isPhoneVerified={isPhoneVerified}
setIsPhoneVerified={async (verified: boolean) => {
setIsPhoneVerified={(verified: boolean) => {
setIsPhoneVerified(verified);
// После верификации проверяем черновики
if (verified && formData.phone && !selectedDraftId) {
const hasDraftsResult = await checkDrafts(formData.unified_id, formData.phone, sessionId);
if (hasDraftsResult) {
setCurrentStep(0); // Переходим к шагу выбора черновика
}
}
// ❌ Убрано: проверка черновиков делается только в onNext
// onNext вызывается после успешной верификации и содержит unified_id
}}
addDebugEvent={addDebugEvent}
/>
@@ -461,7 +681,7 @@ export default function ClaimForm() {
description: 'Полис ERV',
content: (
<Step1Policy
formData={{ ...formData, session_id: sessionId }} // ✅ claim_id уже в formData от n8n
formData={{ ...formData, session_id: sessionIdRef.current }} // ✅ claim_id уже в formData от n8n
updateFormData={updateFormData}
onNext={nextStep}
addDebugEvent={addDebugEvent}
@@ -525,14 +745,14 @@ export default function ClaimForm() {
});
return stepsArray;
}, [formData, documentConfigs, isPhoneVerified, sessionId, nextStep, prevStep, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts]);
}, [formData, documentConfigs, isPhoneVerified, nextStep, prevStep, updateFormData, handleSubmit, setIsPhoneVerified, addDebugEvent, showDraftSelection, selectedDraftId, hasDrafts, handleSelectDraft, handleNewClaim, checkDrafts]);
const handleReset = () => {
setIsSubmitted(false);
setFormData({
voucher: '',
claim_id: undefined, // ✅ Очищаем для новой заявки
session_id: sessionId,
session_id: sessionIdRef.current,
paymentMethod: 'sbp',
});
setCurrentStep(0);
@@ -541,6 +761,41 @@ export default function ClaimForm() {
addDebugEvent('system', 'info', '🔄 Форма сброшена');
};
// Обработчик кнопки "Выход" - завершить сессию и вернуться к Step1Phone
const handleExitToList = useCallback(async () => {
console.log('🚪 Выход из системы');
addDebugEvent('system', 'info', '🚪 Выход из системы');
// Получаем session_token из localStorage
const sessionToken = localStorage.getItem('session_token') || formData.session_id;
if (sessionToken) {
try {
// Вызываем API logout для удаления сессии из Redis
const response = await fetch('/api/v1/session/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_token: sessionToken })
});
if (response.ok) {
console.log('✅ Сессия удалена из Redis');
addDebugEvent('session', 'success', '✅ Сессия завершена');
}
} catch (error) {
console.warn('⚠️ Ошибка при завершении сессии:', error);
}
}
// Удаляем session_token из localStorage
localStorage.removeItem('session_token');
// Сбрасываем форму
handleReset();
message.info('Сессия завершена. До свидания!');
}, [formData.session_id, addDebugEvent]);
return (
<div className="claim-form-container" style={{ padding: '20px', background: '#ffffff' }}>
<Row gutter={16}>
@@ -550,20 +805,42 @@ export default function ClaimForm() {
title="Подать заявку на выплату"
className="claim-form-card"
extra={
!isSubmitted && currentStep > 0 && (
<button
onClick={handleReset}
style={{
padding: '4px 12px',
background: '#fff',
border: '1px solid #d9d9d9',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
🔄 Начать заново
</button>
!isSubmitted && (
<Space>
{/* Кнопка "Выход" - показываем если телефон верифицирован */}
{isPhoneVerified && (
<button
onClick={handleExitToList}
style={{
padding: '4px 12px',
background: '#fff',
border: '1px solid #ff4d4f',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px',
color: '#ff4d4f'
}}
>
🚪 Выход
</button>
)}
{/* Кнопка "Начать заново" - показываем только после шага телефона */}
{currentStep > 0 && (
<button
onClick={handleReset}
style={{
padding: '4px 12px',
background: '#fff',
border: '1px solid #d9d9d9',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
>
🔄 Начать заново
</button>
)}
</Space>
)
}
>
@@ -585,7 +862,13 @@ export default function ClaimForm() {
/>
))}
</Steps>
<div className="steps-content">{steps[currentStep].content}</div>
<div className="steps-content">
{steps[currentStep] ? steps[currentStep].content : (
<div style={{ padding: '40px 0', textAlign: 'center' }}>
<p>Загрузка шага...</p>
</div>
)}
</div>
</>
)}
</Card>