Problem:
- After wizard form submission, need to wait for claim data from n8n
- Claim data comes via Redis channel claim:plan:{session_token}
- Need to display confirmation form with claim data
Solution:
1. Backend: Added SSE endpoint /api/v1/claim-plan/{session_token}
- Subscribes to Redis channel claim:plan:{session_token}
- Streams claim data from n8n to frontend
- Handles timeouts and errors gracefully
2. Frontend: Added subscription to claim:plan channel
- StepWizardPlan: After form submission, subscribes to SSE
- Waits for claim_plan_ready event
- Shows loading message while waiting
- On success: saves claimPlanData and shows confirmation step
3. New component: StepClaimConfirmation
- Displays claim confirmation form in iframe
- Receives claimPlanData from parent
- Generates HTML form (placeholder - should call n8n for real HTML)
- Handles confirmation/cancellation via postMessage
4. ClaimForm: Added conditional step for confirmation
- Shows StepClaimConfirmation when showClaimConfirmation=true
- Step appears after StepWizardPlan
- Only visible when claimPlanData is available
Flow:
1. User fills wizard form → submits
2. Form data sent to n8n via /api/v1/claims/wizard
3. Frontend subscribes to SSE /api/v1/claim-plan/{session_token}
4. n8n processes data → publishes to Redis claim:plan:{session_token}
5. Backend receives → streams to frontend via SSE
6. Frontend receives → shows StepClaimConfirmation
7. User confirms → proceeds to next step
Files:
- backend/app/api/events.py: Added stream_claim_plan endpoint
- frontend/src/components/form/StepWizardPlan.tsx: Added subscribeToClaimPlan
- frontend/src/components/form/StepClaimConfirmation.tsx: New component
- frontend/src/pages/ClaimForm.tsx: Added confirmation step to steps array
215 lines
7.9 KiB
JavaScript
215 lines
7.9 KiB
JavaScript
// 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 }];
|
||
|
||
|
||
|