-- Упрощённый UPSERT для сохранения claim с известным claim_id -- Используется в n8n workflow: form_get (нода claimsave) -- Дата: 2025-11-21 (обновлено: сохранение существующих полей) -- Описание: Простой INSERT/UPDATE для claim, т.к. claim_id уже известен -- ВАЖНО: Сохраняет wizard_plan и другие поля из БД, если не пришли новые -- Входные параметры: -- $1: payload_partial_json (jsonb) - данные формы с wizard_answers, wizard_plan, documents_meta -- $2: claim_id (text) - UUID заявки WITH partial AS ( SELECT $1::jsonb AS p, $2::text AS claim_id_str ), -- Получаем существующий payload из БД (если запись есть) -- ✅ ИСПРАВЛЕНО: Ищем и по ID, и по payload->>'claim_id', чтобы избежать дубликатов existing_claim AS ( SELECT id, payload, created_at FROM clpr_claims WHERE id = (SELECT claim_id_str::uuid FROM partial) OR payload->>'claim_id' = (SELECT claim_id_str FROM partial) ORDER BY CASE WHEN id = (SELECT claim_id_str::uuid FROM partial) THEN 1 ELSE 2 END, updated_at DESC LIMIT 1 ), -- Парсим wizard_answers wizard_answers_parsed AS ( SELECT CASE -- Из 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 THEN (partial.p->>'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 (или берём из существующей записи) wizard_plan_parsed AS ( SELECT CASE -- Сначала пытаемся взять из edit_fields_parsed.wizard_plan_parsed (уже объект) WHEN partial.p->'edit_fields_parsed'->'wizard_plan_parsed' IS NOT NULL AND jsonb_typeof(partial.p->'edit_fields_parsed'->'wizard_plan_parsed') = 'object' THEN partial.p->'edit_fields_parsed'->'wizard_plan_parsed' -- Или из корня как строка WHEN partial.p->>'wizard_plan' IS NOT NULL THEN (partial.p->>'wizard_plan')::jsonb -- Или из корня как объект WHEN partial.p->'wizard_plan' IS NOT NULL AND jsonb_typeof(partial.p->'wizard_plan') = 'object' THEN partial.p->'wizard_plan' -- Или из 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 -- Если нет в payload - берём из существующей записи в БД WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'wizard_plan' IS NOT NULL) THEN (SELECT payload->'wizard_plan' FROM existing_claim) ELSE NULL END AS wizard_plan FROM partial ), -- Парсим answers_prefill (или берём из БД) answers_prefill_parsed AS ( SELECT CASE WHEN partial.p->'answers_prefill' IS NOT NULL AND jsonb_typeof(partial.p->'answers_prefill') = 'array' THEN partial.p->'answers_prefill' WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'answers_prefill' IS NOT NULL) THEN (SELECT payload->'answers_prefill' FROM existing_claim) ELSE '[]'::jsonb END AS answers_prefill FROM partial ), -- Парсим coverage_report (или берём из БД) coverage_report_parsed AS ( SELECT CASE WHEN partial.p->'coverage_report' IS NOT NULL AND jsonb_typeof(partial.p->'coverage_report') = 'object' THEN partial.p->'coverage_report' WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'coverage_report' IS NOT NULL) THEN (SELECT payload->'coverage_report' FROM existing_claim) ELSE NULL END AS coverage_report FROM partial ), -- Парсим ai_agent1_facts (или берём из БД) ai_agent1_facts_parsed AS ( SELECT CASE WHEN partial.p->'ai_agent1_facts' IS NOT NULL AND jsonb_typeof(partial.p->'ai_agent1_facts') = 'object' THEN partial.p->'ai_agent1_facts' WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'ai_agent1_facts' IS NOT NULL) THEN (SELECT payload->'ai_agent1_facts' FROM existing_claim) ELSE NULL END AS ai_agent1_facts FROM partial ), -- Парсим ai_agent13_rag (или берём из БД) ai_agent13_rag_parsed AS ( SELECT CASE WHEN partial.p->'ai_agent13_rag' IS NOT NULL THEN partial.p->'ai_agent13_rag' WHEN partial.p->>'ai_agent13_rag' IS NOT NULL THEN to_jsonb(partial.p->>'ai_agent13_rag') WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->'ai_agent13_rag' IS NOT NULL) THEN (SELECT payload->'ai_agent13_rag' FROM existing_claim) ELSE NULL END AS ai_agent13_rag FROM partial ), -- Парсим problem_description (или берём из БД) problem_description_parsed AS ( SELECT CASE WHEN partial.p->>'problem_description' IS NOT NULL THEN partial.p->>'problem_description' WHEN EXISTS (SELECT 1 FROM existing_claim WHERE payload->>'problem_description' IS NOT NULL) THEN (SELECT payload->>'problem_description' FROM existing_claim) ELSE NULL END AS problem_description FROM partial ), -- UPSERT claim -- ✅ ИСПРАВЛЕНО: Используем ID из existing_claim, если он найден, чтобы избежать дубликатов claim_upsert AS ( INSERT INTO clpr_claims ( id, session_token, unified_id, contact_id, phone, channel, type_code, status_code, payload, created_at, updated_at, expires_at ) SELECT COALESCE( (SELECT id FROM existing_claim), partial.claim_id_str::uuid ), COALESCE( partial.p->>'session_id', partial.p->'edit_fields_parsed'->'body'->>'session_id', partial.p->'edit_fields_raw'->'body'->>'session_id', 'sess-unknown' ), COALESCE( partial.p->>'unified_id', partial.p->'edit_fields_parsed'->'body'->>'unified_id', partial.p->'edit_fields_raw'->'body'->>'unified_id' ), COALESCE( partial.p->>'contact_id', partial.p->'edit_fields_parsed'->'body'->>'contact_id', partial.p->'edit_fields_raw'->'body'->>'contact_id' ), COALESCE( partial.p->>'phone', partial.p->'edit_fields_parsed'->'body'->>'phone', partial.p->'edit_fields_raw'->'body'->>'phone' ), 'web_form', COALESCE(partial.p->>'type_code', 'consumer'), CASE WHEN (SELECT answers->>'docs_exist' FROM wizard_answers_parsed) = 'true' THEN 'in_work' ELSE 'draft' END, jsonb_build_object( 'claim_id', partial.claim_id_str, 'problem_description', (SELECT problem_description FROM problem_description_parsed), 'answers', (SELECT answers FROM wizard_answers_parsed), 'documents_meta', COALESCE(partial.p->'documents_meta', '[]'::jsonb), 'wizard_plan', (SELECT wizard_plan FROM wizard_plan_parsed), 'answers_prefill', (SELECT answers_prefill FROM answers_prefill_parsed), 'coverage_report', (SELECT coverage_report FROM coverage_report_parsed), 'ai_agent1_facts', (SELECT ai_agent1_facts FROM ai_agent1_facts_parsed), 'ai_agent13_rag', (SELECT ai_agent13_rag FROM ai_agent13_rag_parsed), 'phone', COALESCE(partial.p->>'phone', (SELECT payload->>'phone' FROM existing_claim)), 'email', COALESCE(partial.p->>'email', (SELECT payload->>'email' FROM existing_claim)) ), COALESCE( (SELECT created_at FROM existing_claim), now() ), now(), now() + interval '14 days' FROM partial ON CONFLICT (id) DO UPDATE SET session_token = EXCLUDED.session_token, unified_id = COALESCE(EXCLUDED.unified_id, clpr_claims.unified_id), contact_id = COALESCE(EXCLUDED.contact_id, clpr_claims.contact_id), phone = COALESCE(EXCLUDED.phone, clpr_claims.phone), status_code = EXCLUDED.status_code, payload = EXCLUDED.payload, updated_at = now(), expires_at = now() + interval '14 days' RETURNING id, status_code, payload, unified_id, contact_id, phone, session_token ), -- UPSERT documents (если есть) docs_upsert AS ( INSERT INTO clpr_claim_documents ( claim_id, field_name, file_id, uploaded_at, file_name, original_file_name ) SELECT partial.claim_id_str AS claim_id, doc.field_name, doc.file_id, COALESCE((doc.uploaded_at)::timestamptz, now()), doc.file_name, doc.original_file_name FROM partial 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 ) WHERE partial.p->'documents_meta' IS NOT NULL AND jsonb_array_length(partial.p->'documents_meta') > 0 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, file_name, original_file_name ) -- Возвращаем результат SELECT (SELECT jsonb_build_object( 'claim_id', cu.id::text, 'claim_id_str', (cu.payload->>'claim_id'), 'status_code', cu.status_code, 'unified_id', cu.unified_id, 'contact_id', cu.contact_id, 'phone', cu.phone, 'session_token', cu.session_token, 'payload', cu.payload ) FROM claim_upsert cu) AS claim, (SELECT jsonb_agg(jsonb_build_object( 'id', id, 'field_name', field_name, 'file_id', file_id, 'file_name', file_name, 'original_file_name', original_file_name )) FROM docs_upsert) AS documents; /* ============================================================================ КЛЮЧЕВЫЕ ИЗМЕНЕНИЯ (2025-11-21): ============================================================================ 1. Добавлена CTE "existing_claim" - читает существующий payload из БД 2. Все парсеры (wizard_plan, answers_prefill, coverage_report и т.д.) теперь проверяют БД, если поле не пришло в payload_partial_json 3. Это критично для workflow form_get, где wizard_plan создаётся на Step 2, а файлы загружаются на Step 3 (без повторной отправки wizard_plan) ============================================================================ ИСПРАВЛЕНИЕ ДУБЛИКАТОВ (2025-11-24): ============================================================================ ПРОБЛЕМА: Создавались дубликаты записей с одинаковым claim_id, но разными ID Причина: Разные SQL запросы использовали разные подходы: - Одни использовали claim_id как UUID для ID (partial.claim_id_str::uuid) - Другие искали по payload->>'claim_id' и создавали новый UUID, если не находили РЕШЕНИЕ: 1. existing_claim теперь ищет и по ID, и по payload->>'claim_id': WHERE id = claim_id_str::uuid OR payload->>'claim_id' = claim_id_str 2. INSERT использует ID из existing_claim, если он найден: COALESCE((SELECT id FROM existing_claim), partial.claim_id_str::uuid) 3. Это гарантирует, что всегда используется одна и та же запись ============================================================================ ПРИМЕРЫ ИСПОЛЬЗОВАНИЯ: ============================================================================ 1. Первое сохранение (после Step 2 - описание проблемы): SELECT * FROM ... WHERE ... = ( '{ "session_id": "sess_xxx", "unified_id": "usr_xxx", "contact_id": "12345", "phone": "79262306381", "problem_description": "Не вернули деньги...", "wizard_plan": {...полный wizard_plan...}, "ai_agent1_facts": {...}, "ai_agent13_rag": "...", "answers_prefill": [...], "coverage_report": {...} }'::jsonb, 'uuid-here'::text ); Результат: Создаётся claim с wizard_plan ✅ --- 2. Обновление с файлами (Step 3 - загрузка документов): SELECT * FROM ... WHERE ... = ( '{ "session_id": "sess_xxx", "unified_id": "usr_xxx", "wizard_answers": {"q1": "answer1"}, "documents_meta": [ { "field_name": "uploads[0][0]", "file_id": "clientright/0/file.pdf", "file_name": "file.pdf", "original_file_name": "original.pdf", "uploaded_at": "2025-11-21T12:00:00Z" } ] }'::jsonb, 'СУЩЕСТВУЮЩИЙ-uuid'::text ); Результат: - Обновляется answers ✅ - Добавляются documents_meta ✅ - wizard_plan СОХРАНЯЕТСЯ из БД ✅ (не затирается!) --- 3. Сохранение БЕЗ файлов (только answers): SELECT * FROM ... WHERE ... = ( '{ "session_id": "sess_xxx", "wizard_answers": {"q1": "answer1"}, "documents_meta": [] }'::jsonb, 'uuid-here'::text ); Результат: - status_code = 'draft' (т.к. docs_exist != true) - wizard_plan сохраняется из БД ✅ ============================================================================ ОЖИДАЕМЫЙ РЕЗУЛЬТАТ: ============================================================================ { "claim": { "claim_id": "uuid", "claim_id_str": "uuid", "status_code": "draft" or "in_work", "unified_id": "usr_xxx", "contact_id": "12345", "phone": "79262306381", "session_token": "sess_xxx", "payload": { "claim_id": "uuid", "problem_description": "...", "answers": {...}, "documents_meta": [...], "wizard_plan": {...}, // ← СОХРАНЯЕТСЯ из БД! "answers_prefill": [...], "coverage_report": {...}, "ai_agent1_facts": {...}, "ai_agent13_rag": "..." } }, "documents": [...] } ============================================================================ TROUBLESHOOTING: ============================================================================ Проблема: wizard_plan всё равно NULL после загрузки файлов Причина: В n8n workflow form_get не передаётся claim_id в payload Решение: Убедиться, что в set_token1 извлекается claim_id из webhook: "claim_id": "={{ $('Webhook').item.json.body.claim_id }}" Проблема: Затираются ai_agent1_facts после Step 3 Причина: Не включены в payload при отправке Решение: SQL сохраняет их из БД автоматически ✅ */