Files
aiform_prod/docs/N8N_FLIGHTS_WORKING_WORKFLOW.json
AI Assistant 2e45786e46 feat: Telegram Mini App integration and UX improvements
- Добавлена полная интеграция с Telegram Mini App (динамическая загрузка SDK)
- Отдельный компактный дизайн для Telegram Mini App
- Добавлен loader при инициализации (предотвращает мелькание SMS-авторизации)
- Улучшена навигация: кнопки "Назад" и "К списку заявок" теперь сохраняют авторизацию
- Telegram Mini App: кнопка "Выход" просто закрывает приложение
- Telegram Mini App: заявки "В работе" скрыты из списка
- Веб-версия: для заявок "В работе" добавлена кнопка "Просмотреть в Telegram" (ссылка на @klientprav_bot)
- Telegram Mini App: кнопки действий в черновиках расположены вертикально
- Веб-версия: убрано отображение номера телефона в приветствии
- Исправлена проблема с возвратом к списку черновиков (не требует повторной SMS-авторизации)
- Заблокировано удаление и редактирование заявок со статусом "В работе"
- Добавлена документация по Telegram Mini App интеграции
2026-01-29 16:12:48 +03:00

54 lines
18 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{
"nodes": [
{
"parameters": {
"jsCode": "// ============================================================================\n// n8n Code Node: Обработка данных о рейсах → Base64 HTML\n// ============================================================================\n// Вход: [{ data: [{ body: { flights: [...] }}, { body: { data: [...] }}] }]\n// Выход: base64 HTML\n// ============================================================================\n\nconst inputItems = $input.all();\n\n// ================== FALLBACK ==================\nif (!inputItems || inputItems.length === 0) {\n const html = '<!DOCTYPE html><html><body><h1>Ошибка: данные не получены</h1></body></html>';\n const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');\n \n return [{\n json: {\n html_base64: htmlBase64,\n html: html,\n flights_count: 0,\n error: 'Нет входных данных'\n }\n }];\n}\n\n// ================== ИЗВЛЕЧЕНИЕ ДАННЫХ ==================\n// Структура: [{ data: [{ body: { flights: [...] }}, { body: { data: [...] }}] }]\nlet flightAwareData = [];\nlet flightRadar24Data = [];\n\ntry {\n const firstItem = inputItems[0];\n if (firstItem && firstItem.json && firstItem.json.data && Array.isArray(firstItem.json.data)) {\n // Первый элемент массива data - FlightAware\n if (firstItem.json.data[0] && firstItem.json.data[0].body && firstItem.json.data[0].body.flights) {\n flightAwareData = Array.isArray(firstItem.json.data[0].body.flights) \n ? firstItem.json.data[0].body.flights \n : [];\n }\n \n // Второй элемент массива data - FlightRadar24\n if (firstItem.json.data[1] && firstItem.json.data[1].body && firstItem.json.data[1].body.data) {\n flightRadar24Data = Array.isArray(firstItem.json.data[1].body.data) \n ? firstItem.json.data[1].body.data \n : [];\n }\n }\n} catch (e) {\n console.log('⚠️ Ошибка извлечения данных:', e.message);\n}\n\n// ================== УТИЛИТЫ ==================\nconst safeStr = v => (v == null ? '' : String(v));\nconst safeDate = v => {\n if (!v) return '—';\n try {\n const d = new Date(v);\n return isNaN(d.getTime()) ? '—' : d.toLocaleString('ru-RU', {\n timeZone: 'UTC',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit'\n });\n } catch {\n return '—';\n }\n};\n\nconst formatDuration = s => !s ? '—' : `${Math.floor(s / 3600)}ч ${Math.floor((s % 3600) / 60)}м`;\nconst formatDistance = km => !km ? '—' : `${Number(km).toFixed(2)} км`;\n\n// ================== MERGE ПО REGISTRATION ==================\nconst flightsMap = new Map();\n\n// Добавляем данные из FlightAware\nflightAwareData.forEach(f => {\n const reg = safeStr(f.registration).trim();\n if (!reg) return;\n if (!flightsMap.has(reg)) {\n flightsMap.set(reg, {\n registration: reg,\n flightNumber: safeStr(f.flight_number),\n ident: safeStr(f.ident),\n identIata: safeStr(f.ident_iata),\n aircraftType: safeStr(f.aircraft_type),\n fa: f,\n fr: null\n });\n } else {\n flightsMap.get(reg).fa = f;\n }\n});\n\n// Добавляем данные из FlightRadar24\nflightRadar24Data.forEach(f => {\n const reg = safeStr(f.reg).trim();\n if (!reg) return;\n if (!flightsMap.has(reg)) {\n flightsMap.set(reg, {\n registration: reg,\n flightNumber: safeStr(f.flight),\n ident: safeStr(f.callsign),\n identIata: safeStr(f.flight),\n aircraftType: safeStr(f.type),\n fa: null,\n fr: f\n });\n } else {\n flightsMap.get(reg).fr = f;\n }\n});\n\nconst flights = Array.from(flightsMap.values());\n\n// ================== HTML GENERATION ==================\nconst generateFlightCard = f => {\n const fa = f.fa;\n const fr = f.fr;\n \n let card = `\n<div class=\"flight-card\">\n <div class=\"flight-header\">\n <h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>\n <span class=\"registration\">${f.registration}</span>\n </div>\n <div class=\"flight-info\">\n <div class=\"info-row\">\n <span class=\"label\">Тип самолёта:</span>\n <span class=\"value\">${f.aircraftType || '—'}</span>\n </div>\n <div class=\"info-row\">\n <span class=\"label\">Идентификатор:</span>\n <span class=\"value\">${f.ident || '—'} (${f.identIata || '—'})</span>\n </div>\n </div>`;\n\n // Данные из FlightAware\n if (fa) {\n card += `\n <div class=\"source-section\">\n <div class=\"source-header\">\n <span class=\"source-badge source-flightaware\">FlightAware</span>\n </div>\n <div class=\"source-content\">\n <div class=\"route-info\">\n <div class=\"route-item\">\n <span class=\"route-label\">Откуда:</span>\n <span class=\"route-value\">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>\n </div>\n <div class=\"route-item\">\n <span class=\"route-label\">Куда:</span>\n <span class=\"route-value\">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>\n </div>\n </div>\n <div class=\"timeline\">\n <div class=\"timeline-item\">\n <span class=\"timeline-label\">Плановый вылет:</span>\n <span class=\"timeline-value\">${safeDate(fa.scheduled_out)}</span>\n </div>\n <div class=\"timeline-item\">\n <span class=\"timeline-label\">Фактический вылет:</span>\n <span class=\"timeline-value\">${safeDate(fa.actual_out)}</span>\n </div>\n <div class=\"timeline-item\">\n <span class=\"timeline-label\">Взлёт:</span>\n <span class=\"timeline-value\">${safeDate(fa.actual_off)} ${fa.actual_runway_off ? `(ВПП ${fa.actual_runway_off})` : ''}</span>\n </div>\n <div class=\"timeline-item\">\n <span class=\"timeline-label\">Посадка:</span>\n <span class=\"timeline-value\">${safeDate(fa.actual_on)} ${fa.actual_runway_on ? `(ВПП ${fa.actual_runway_on})` : ''}</span>\n </div>\n <div class=\"timeline-item\">\n <span class=\"timeline-label\">Фактический прилёт:</span>\n <span class=\"timeline-value\">${safeDate(fa.actual_in)}</span>\n </div>\n </div>\n <div class=\"status-info\">\n <div class=\"status-item\">\n <span class=\"status-label\">Статус:</span>\n <span class=\"status-value\">${safeStr(fa.status || '—')}</span>\n </div>\n ${fa.departure_delay !== null && fa.departure_delay !== undefined ? `\n <div class=\"status-item\">\n <span class=\"status-label\">Задержка вылета:</span>\n <span class=\"status-value ${fa.departure_delay < 0 ? 'delay-negative' : 'delay-positive'}\">${fa.departure_delay > 0 ? '+' : ''}${Math.floor(fa.departure_delay / 60)} мин</span>\n </div>\n ` : ''}\n ${fa.arrival_delay !== null && fa.arrival_delay !== undefined ? `\n <div class=\"status-item\">\n <span class=\"status-label\">Задержка прилёта:</span>\n <span class=\"status-value ${fa.arrival_delay < 0 ? 'delay-negative' : 'delay-positive'}\">${fa.arrival_delay > 0 ? '+' : ''}${Math.floor(fa.arrival_delay / 60)} мин</span>\n </div>\n ` : ''}\n ${fa.gate_origin ? `\n <div class=\"status-item\">\n <span class=\"status-label\">Гейт вылета:</span>\n <span class=\"status-value\">${fa.gate_origin}</span>\n </div>\n ` : ''}\n ${fa.gate_destination ? `\n <div class=\"status-item\">\n <span class=\"status-label\">Гейт прилёта:</span>\n <span class=\"status-value\">${fa.gate_destination}</span>\n </div>\n ` : ''}\n ${fa.baggage_claim ? `\n <div class=\"status-item\">\n <span class=\"status-label\">Выдача багажа:</span>\n <span class=\"status-value\">${fa.baggage_claim}</span>\n </div>\n ` : ''}\n </div>\n </div>\n </div>`;\n } else {\n card += `\n <div class=\"source-section\">\n <div class=\"source-header\">\n <span class=\"source-badge source-flightaware\">FlightAware</span>\n <span class=\"source-missing\">Данные не получены</span>\n </div>\n </div>`;\n }\n\n // Данные из FlightRadar24\n if (fr) {\n card += `\n <div class=\"source-section\">\n <div class=\"source-header\">\n <span class=\"source-badge source-flightradar24\">FlightRadar24</span>\n </div>\n <div class=\"source-content\">\n <div class=\"route-info\">\n <div class=\"route-item\">\n <span class=\"route-label\">Откуда:</span>\n <span class=\"route-value\">${safeStr(fr.orig_iata || '—')} (${safeStr(fr.orig_icao || '—')})</span>\n </div>\n <div class=\"route-item\">\n <span class=\"route-label\">Куда:</span>\n <span class=\"route-value\">${safeStr(fr.dest_iata || '—')} (${safeStr(fr.dest_icao || '—')})</span>\n </div>\n </div>\n <div class=\"timeline\">\n <div class=\"timeline-item\">\n <span class=\"timeline-label\">Взлёт:</span>\n <span class=\"timeline-value\">${safeDate(fr.datetime_takeoff)} ${fr.runway_takeoff ? `(ВПП ${fr.runway_takeoff})` : ''}</span>\n </div>\n <div class=\"timeline-item\">\n <span class=\"timeline-label\">Посадка:</span>\n <span class=\"timeline-value\">${safeDate(fr.datetime_landed)} ${fr.runway_landed ? `(ВПП ${fr.runway_landed})` : ''}</span>\n </div>\n </div>\n <div class=\"status-info\">\n <div class=\"status-item\">\n <span class=\"status-label\">Время полёта:</span>\n <span class=\"status-value\">${formatDuration(fr.flight_time)}</span>\n </div>\n <div class=\"status-item\">\n <span class=\"status-label\">Фактическое расстояние:</span>\n <span class=\"status-value\">${formatDistance(fr.actual_distance)}</span>\n </div>\n <div class=\"status-item\">\n <span class=\"status-label\">Кратчайшее расстояние:</span>\n <span class=\"status-value\">${formatDistance(fr.circle_distance)}</span>\n </div>\n <div class=\"status-item\">\n <span class=\"status-label\">Статус полёта:</span>\n <span class=\"status-value\">${fr.flight_ended ? 'Завершён' : 'В процессе'}</span>\n </div>\n </div>\n </div>\n </div>`;\n } else {\n card += `\n <div class=\"source-section\">\n <div class=\"source-header\">\n <span class=\"source-badge source-flightradar24\">FlightRadar24</span>\n <span class=\"source-missing\">Данные не получены</span>\n </div>\n </div>`;\n }\n\n card += `</div>`;\n return card;\n};\n\n// Генерация полного HTML\nconst now = new Date();\nconst reportDate = now.toLocaleString('ru-RU', {\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: '2-digit',\n minute: '2-digit'\n});\n\nconst html = `<!DOCTYPE html>\n<html lang=\"ru\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Отчёт о рейсах</title>\n <style>\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; padding: 20px; }\n .container { max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }\n .header { border-bottom: 3px solid #2563eb; padding-bottom: 20px; margin-bottom: 30px; }\n .header h1 { color: #1e40af; font-size: 28px; margin-bottom: 10px; }\n .header-meta { color: #666; font-size: 14px; }\n .sources-info { display: flex; gap: 15px; margin-top: 10px; flex-wrap: wrap; }\n .source-tag { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }\n .source-tag.available { background: #d1fae5; color: #065f46; }\n .source-tag.unavailable { background: #fee2e2; color: #991b1b; }\n .flight-card { border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 25px; overflow: hidden; background: white; }\n .flight-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; display: flex; justify-content: space-between; align-items: center; }\n .flight-header h2 { font-size: 24px; margin: 0; }\n .registration { background: rgba(255,255,255,0.2); padding: 6px 12px; border-radius: 4px; font-weight: 600; font-size: 14px; }\n .flight-info { padding: 15px 20px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }\n .info-row { display: flex; margin-bottom: 8px; }\n .info-row .label { font-weight: 600; color: #4b5563; width: 150px; flex-shrink: 0; }\n .info-row .value { color: #111827; }\n .source-section { border-top: 1px solid #e5e7eb; padding: 20px; }\n .source-section:first-of-type { border-top: none; }\n .source-header { display: flex; align-items: center; gap: 10px; margin-bottom: 15px; }\n .source-badge { display: inline-block; padding: 6px 14px; border-radius: 6px; font-size: 13px; font-weight: 600; color: white; }\n .source-badge.source-flightaware { background: #3b82f6; }\n .source-badge.source-flightradar24 { background: #10b981; }\n .source-missing { color: #ef4444; font-size: 13px; font-style: italic; }\n .source-content { margin-left: 0; }\n .route-info { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px; padding: 15px; background: #f9fafb; border-radius: 6px; }\n .route-item { display: flex; flex-direction: column; }\n .route-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }\n .route-value { font-size: 16px; font-weight: 600; color: #111827; }\n .timeline { margin-bottom: 20px; }\n .timeline-item { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }\n .timeline-item:last-child { border-bottom: none; }\n .timeline-label { font-weight: 500; color: #4b5563; width: 180px; flex-shrink: 0; }\n .timeline-value { color: #111827; text-align: right; }\n .status-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; padding: 15px; background: #f9fafb; border-radius: 6px; }\n .status-item { display: flex; flex-direction: column; }\n .status-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }\n .status-value { font-size: 14px; font-weight: 600; color: #111827; }\n .delay-negative { color: #10b981; }\n .delay-positive { color: #ef4444; }\n .no-data { text-align: center; padding: 60px 20px; color: #6b7280; font-size: 18px; }\n @media print { body { background: white; padding: 0; } .container { box-shadow: none; padding: 20px; } .flight-card { page-break-inside: avoid; margin-bottom: 20px; } }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <div class=\"header\">\n <h1>Отчёт о рейсах</h1>\n <div class=\"header-meta\">\n <div>Дата формирования: ${reportDate}</div>\n <div class=\"sources-info\">\n <span class=\"source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}\">\n FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}\n </span>\n <span class=\"source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}\">\n FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}\n </span>\n </div>\n </div>\n </div>\n <div class=\"flights-container\">\n ${flights.length ? flights.map(generateFlightCard).join('') : '<div class=\"no-data\">Данные о рейсах не найдены</div>'}\n </div>\n </div>\n</body>\n</html>`;\n\n// ================== HTML → BASE64 ==================\nconst htmlBase64 = Buffer.from(html, 'utf8').toString('base64');\n\n// ================== RETURN ==================\nreturn [{\n json: {\n html_base64: htmlBase64,\n html: html,\n flights_count: flights.length,\n sources: {\n flightaware: { available: flightAwareData.length > 0, count: flightAwareData.length },\n flightradar24: { available: flightRadar24Data.length > 0, count: flightRadar24Data.length }\n },\n generated_at: now.toISOString()\n }\n}];\n"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
384,
128
],
"id": "a44a16f4-0ae7-4947-8f2b-3d70a6e6cfe0",
"name": "причесываем данные"
},
{
"parameters": {
"method": "POST",
"url": "http://147.45.146.17:3000/pdf?token=9ahhnpjkchxtcho9",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"url\": \"data:text/html;base64, {{ $json.html_base64 }}\",\n \"options\": {\n \"format\": \"A4\",\n \"printBackground\": true,\n \"margin\": {\n \"top\": \"20mm\",\n \"right\": \"15mm\",\n \"bottom\": \"20mm\",\n \"left\": \"20mm\"\n }\n }\n}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.3,
"position": [
624,
128
],
"id": "fbbf533a-b3b8-4aba-8a38-106545a5a9e2",
"name": "HTTP Request: Browserless PDF"
}
],
"connections": {
"причесываем данные": {
"main": [
[
{
"node": "HTTP Request: Browserless PDF",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "ad5e78bf27056f72474a633d21d938f3223861ac866f2ebe4e46b867e404f489"
}
}