Save all currently accumulated repository changes as a backup snapshot for Gitea so no local work is lost.
446 lines
21 KiB
JavaScript
446 lines
21 KiB
JavaScript
// ============================================================================
|
||
// n8n Code Node: Обработка данных о рейсах → Base64 HTML
|
||
// ============================================================================
|
||
// Вход: [{ data: [{ body: { flights: [...] }}, { body: { data: [...] }}] }]
|
||
// Выход: base64 HTML
|
||
// ============================================================================
|
||
|
||
const inputItems = $input.all();
|
||
|
||
// ================== FALLBACK ==================
|
||
if (!inputItems || inputItems.length === 0) {
|
||
const html = '<!DOCTYPE html><html><body><h1>Ошибка: данные не получены</h1></body></html>';
|
||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||
|
||
return [{
|
||
json: {
|
||
html_base64: htmlBase64,
|
||
html: html,
|
||
flights_count: 0,
|
||
error: 'Нет входных данных'
|
||
}
|
||
}];
|
||
}
|
||
|
||
// ================== ИЗВЛЕЧЕНИЕ ДАННЫХ ==================
|
||
let flightAwareData = [];
|
||
let flightRadar24Data = [];
|
||
let requestData = null;
|
||
let flightRadar24Error = null;
|
||
|
||
try {
|
||
const firstItem = inputItems[0];
|
||
if (firstItem && firstItem.json && firstItem.json.data && Array.isArray(firstItem.json.data)) {
|
||
if (firstItem.json.data[0] && firstItem.json.data[0].body) {
|
||
if (firstItem.json.data[0].body.flights) {
|
||
flightAwareData = Array.isArray(firstItem.json.data[0].body.flights)
|
||
? firstItem.json.data[0].body.flights
|
||
: [];
|
||
}
|
||
}
|
||
|
||
if (firstItem.json.data[1]) {
|
||
if (firstItem.json.data[1].error) {
|
||
flightRadar24Error = firstItem.json.data[1].error;
|
||
flightRadar24Data = [];
|
||
} else if (firstItem.json.data[1].body && firstItem.json.data[1].body.data) {
|
||
flightRadar24Data = Array.isArray(firstItem.json.data[1].body.data)
|
||
? firstItem.json.data[1].body.data
|
||
: [];
|
||
}
|
||
}
|
||
|
||
if (firstItem.json.data[2] && firstItem.json.data[2].flight_number) {
|
||
requestData = {
|
||
flight_number: firstItem.json.data[2].flight_number,
|
||
departure_date_local: firstItem.json.data[2].departure_date_local || null,
|
||
arrival_date_local: firstItem.json.data[2].arrival_date_local || null
|
||
};
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.log('⚠️ Ошибка извлечения данных:', e.message);
|
||
}
|
||
|
||
// ================== УТИЛИТЫ ==================
|
||
const safeStr = v => (v == null ? '' : String(v));
|
||
const safeDate = v => {
|
||
if (!v) return '—';
|
||
try {
|
||
const d = new Date(v);
|
||
return isNaN(d.getTime()) ? '—' : d.toLocaleString('ru-RU', {
|
||
timeZone: 'UTC',
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
} catch {
|
||
return '—';
|
||
}
|
||
};
|
||
|
||
const formatDuration = s => !s ? '—' : `${Math.floor(s / 3600)}ч ${Math.floor((s % 3600) / 60)}м`;
|
||
const formatDistance = km => !km ? '—' : `${Number(km).toFixed(2)} км`;
|
||
|
||
// ================== MERGE: все рейсы FA + FR24 по registration ==================
|
||
const flightsMap = new Map();
|
||
|
||
// Ключ для рейса без registration (уникальный per FA-рейс)
|
||
const faKey = (f, i) => {
|
||
const reg = safeStr(f.registration).trim();
|
||
if (reg) return reg;
|
||
return (f.fa_flight_id || `FA-${f.ident || 'X'}-${i}-${f.origin?.code_icao}-${f.destination?.code_icao}`).trim();
|
||
};
|
||
|
||
// Добавляем все рейсы из FlightAware (в т.ч. без registration, отменённые)
|
||
flightAwareData.forEach((f, i) => {
|
||
const key = faKey(f, i);
|
||
const reg = safeStr(f.registration).trim();
|
||
if (flightsMap.has(key)) {
|
||
flightsMap.get(key).fa = f;
|
||
} else {
|
||
flightsMap.set(key, {
|
||
registration: reg || '—',
|
||
flightNumber: safeStr(f.flight_number),
|
||
ident: safeStr(f.ident),
|
||
identIata: safeStr(f.ident_iata),
|
||
aircraftType: safeStr(f.aircraft_type),
|
||
fa: f,
|
||
fr: null
|
||
});
|
||
}
|
||
});
|
||
|
||
// Добавляем данные из FlightRadar24 (мерж только по registration)
|
||
flightRadar24Data.forEach(f => {
|
||
const reg = safeStr(f.reg).trim();
|
||
if (!reg) return;
|
||
if (flightsMap.has(reg)) {
|
||
flightsMap.get(reg).fr = f;
|
||
} else {
|
||
flightsMap.set(reg, {
|
||
registration: reg,
|
||
flightNumber: safeStr(f.flight),
|
||
ident: safeStr(f.callsign),
|
||
identIata: safeStr(f.flight),
|
||
aircraftType: safeStr(f.type),
|
||
fa: null,
|
||
fr: f
|
||
});
|
||
}
|
||
});
|
||
|
||
// ================== ДОБАВЛЕНИЕ ЗАПРОШЕННЫХ РЕЙСОВ БЕЗ ДАННЫХ ==================
|
||
const allInputItems = $input.all();
|
||
const firstItemForRequest = inputItems[0];
|
||
let requestedFlightNumbers = new Set();
|
||
let requestFlightNumber = null;
|
||
let requestDepartureDate = null;
|
||
let requestArrivalDate = null;
|
||
|
||
if (requestData) {
|
||
requestFlightNumber = requestData.flight_number;
|
||
requestDepartureDate = requestData.departure_date_local;
|
||
requestArrivalDate = requestData.arrival_date_local;
|
||
if (requestFlightNumber) requestedFlightNumbers.add(String(requestFlightNumber));
|
||
}
|
||
|
||
allInputItems.forEach(item => {
|
||
if (!item?.json) return;
|
||
if (item.json.flight_number && (item.json.departure_date_local || item.json.arrival_date_local) && !requestFlightNumber) {
|
||
requestFlightNumber = item.json.flight_number || item.json.ident || item.json.flight;
|
||
requestDepartureDate = item.json.departure_date_local || null;
|
||
requestArrivalDate = item.json.arrival_date_local || null;
|
||
if (requestFlightNumber) requestedFlightNumbers.add(String(requestFlightNumber));
|
||
}
|
||
if (item.json.request_flight_number && !requestFlightNumber) {
|
||
requestFlightNumber = item.json.request_flight_number;
|
||
requestDepartureDate = item.json.request_departure_date || null;
|
||
requestArrivalDate = item.json.request_arrival_date || null;
|
||
if (requestFlightNumber) requestedFlightNumbers.add(String(requestFlightNumber));
|
||
}
|
||
['flight_number', 'ident', 'flight'].forEach(k => { if (item.json[k]) requestedFlightNumbers.add(String(item.json[k])); });
|
||
});
|
||
|
||
if (firstItemForRequest?.json) {
|
||
if (Array.isArray(firstItemForRequest.json.requested_flights)) {
|
||
firstItemForRequest.json.requested_flights.forEach(flight => {
|
||
const n = typeof flight === 'string' ? flight : (flight.flight_number || flight.ident || flight);
|
||
if (n) requestedFlightNumbers.add(n);
|
||
});
|
||
}
|
||
const one = firstItemForRequest.json.flight_number || firstItemForRequest.json.ident || firstItemForRequest.json.flight;
|
||
if (one) requestedFlightNumbers.add(one);
|
||
if (Array.isArray(firstItemForRequest.json.flight_numbers)) {
|
||
firstItemForRequest.json.flight_numbers.forEach(n => { if (n) requestedFlightNumbers.add(String(n)); });
|
||
}
|
||
}
|
||
|
||
requestedFlightNumbers.forEach(flightNum => {
|
||
let found = false;
|
||
flightsMap.forEach((flight) => {
|
||
if (flight.flightNumber === flightNum || flight.ident === flightNum || flight.identIata === flightNum) found = true;
|
||
});
|
||
if (!found) {
|
||
flightsMap.set(`REQUESTED-${flightNum}`, {
|
||
registration: '—',
|
||
flightNumber: flightNum,
|
||
ident: flightNum,
|
||
identIata: flightNum,
|
||
aircraftType: '—',
|
||
fa: null,
|
||
fr: null,
|
||
isRequested: true
|
||
});
|
||
}
|
||
});
|
||
|
||
const flights = Array.from(flightsMap.values());
|
||
|
||
// ================== HTML: карточка рейса ==================
|
||
const generateFlightCard = (f) => {
|
||
const fa = f.fa;
|
||
const fr = f.fr;
|
||
|
||
if (f.isRequested && !fa && !fr) {
|
||
const matchesRequest = requestFlightNumber && (String(f.flightNumber) === String(requestFlightNumber) || String(f.ident) === String(requestFlightNumber));
|
||
let requestInfo = '';
|
||
if (matchesRequest) {
|
||
if (requestDepartureDate) requestInfo += `<div class="info-row"><span class="label">Дата вылета (запрос):</span><span class="value">${requestDepartureDate}</span></div>`;
|
||
if (requestArrivalDate) requestInfo += `<div class="info-row"><span class="label">Дата прилёта (запрос):</span><span class="value">${requestArrivalDate}</span></div>`;
|
||
}
|
||
return `
|
||
<div class="flight-card">
|
||
<div class="flight-header">
|
||
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
|
||
<span class="registration">Запрошен</span>
|
||
</div>
|
||
<div class="flight-info">
|
||
<div class="info-row"><span class="label">Запрошенный рейс:</span><span class="value">${f.flightNumber || f.ident || '—'}</span></div>
|
||
${requestInfo}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
const isCancelled = !!(fa && fa.cancelled);
|
||
const headerBadge = isCancelled
|
||
? '<span class="badge-cancelled">Отменён</span>'
|
||
: `<span class="registration">${f.registration || '—'}</span>`;
|
||
|
||
let card = `
|
||
<div class="flight-card${isCancelled ? ' cancelled' : ''}">
|
||
<div class="flight-header">
|
||
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
|
||
${headerBadge}
|
||
</div>
|
||
<div class="flight-info">
|
||
<div class="info-row"><span class="label">Тип самолёта:</span><span class="value">${f.aircraftType || '—'}</span></div>
|
||
<div class="info-row"><span class="label">Идентификатор:</span><span class="value">${f.ident || '—'} (${f.identIata || '—'})</span></div>
|
||
</div>`;
|
||
|
||
if (fa) {
|
||
card += `
|
||
<div class="source-section">
|
||
<div class="source-header">
|
||
<span class="source-badge source-flightaware">FlightAware</span>
|
||
</div>
|
||
<div class="source-content">
|
||
${isCancelled ? '<div class="status-cancelled">✕ Рейс отменён</div>' : ''}
|
||
<div class="route-info">
|
||
<div class="route-item">
|
||
<span class="route-label">Откуда:</span>
|
||
<span class="route-value">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>
|
||
</div>
|
||
<div class="route-item">
|
||
<span class="route-label">Куда:</span>
|
||
<span class="route-value">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>
|
||
</div>
|
||
</div>
|
||
<div class="timeline">
|
||
<div class="timeline-item">
|
||
<span class="timeline-label">Плановый вылет:</span>
|
||
<span class="timeline-value">${safeDate(fa.scheduled_out)}</span>
|
||
</div>
|
||
<div class="timeline-item">
|
||
<span class="timeline-label">Фактический вылет:</span>
|
||
<span class="timeline-value">${safeDate(fa.actual_out)}</span>
|
||
</div>
|
||
<div class="timeline-item">
|
||
<span class="timeline-label">Взлёт:</span>
|
||
<span class="timeline-value">${safeDate(fa.actual_off)} ${fa.actual_runway_off ? `(ВПП ${fa.actual_runway_off})` : ''}</span>
|
||
</div>
|
||
<div class="timeline-item">
|
||
<span class="timeline-label">Посадка:</span>
|
||
<span class="timeline-value">${safeDate(fa.actual_on)} ${fa.actual_runway_on ? `(ВПП ${fa.actual_runway_on})` : ''}</span>
|
||
</div>
|
||
<div class="timeline-item">
|
||
<span class="timeline-label">Фактический прилёт:</span>
|
||
<span class="timeline-value">${safeDate(fa.actual_in)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="status-info">
|
||
<div class="status-item">
|
||
<span class="status-label">Статус:</span>
|
||
<span class="status-value">${safeStr(fa.status || '—')}</span>
|
||
</div>
|
||
${fa.departure_delay != null ? `
|
||
<div class="status-item">
|
||
<span class="status-label">Задержка вылета:</span>
|
||
<span class="status-value ${fa.departure_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.departure_delay > 0 ? '+' : ''}${Math.floor(fa.departure_delay / 60)} мин</span>
|
||
</div>
|
||
` : ''}
|
||
${fa.arrival_delay != null ? `
|
||
<div class="status-item">
|
||
<span class="status-label">Задержка прилёта:</span>
|
||
<span class="status-value ${fa.arrival_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.arrival_delay > 0 ? '+' : ''}${Math.floor(fa.arrival_delay / 60)} мин</span>
|
||
</div>
|
||
` : ''}
|
||
${fa.gate_origin ? `<div class="status-item"><span class="status-label">Гейт вылета:</span><span class="status-value">${fa.gate_origin}</span></div>` : ''}
|
||
${fa.gate_destination ? `<div class="status-item"><span class="status-label">Гейт прилёта:</span><span class="status-value">${fa.gate_destination}</span></div>` : ''}
|
||
${fa.baggage_claim ? `<div class="status-item"><span class="status-label">Выдача багажа:</span><span class="status-value">${fa.baggage_claim}</span></div>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
if (fr) {
|
||
card += `
|
||
<div class="source-section">
|
||
<div class="source-header">
|
||
<span class="source-badge source-flightradar24">FlightRadar24</span>
|
||
</div>
|
||
<div class="source-content">
|
||
<div class="route-info">
|
||
<div class="route-item"><span class="route-label">Откуда:</span><span class="route-value">${safeStr(fr.orig_iata || '—')} (${safeStr(fr.orig_icao || '—')})</span></div>
|
||
<div class="route-item"><span class="route-label">Куда:</span><span class="route-value">${safeStr(fr.dest_iata || '—')} (${safeStr(fr.dest_icao || '—')})</span></div>
|
||
</div>
|
||
<div class="timeline">
|
||
<div class="timeline-item">
|
||
<span class="timeline-label">Взлёт:</span>
|
||
<span class="timeline-value">${safeDate(fr.datetime_takeoff)} ${fr.runway_takeoff ? `(ВПП ${fr.runway_takeoff})` : ''}</span>
|
||
</div>
|
||
<div class="timeline-item">
|
||
<span class="timeline-label">Посадка:</span>
|
||
<span class="timeline-value">${safeDate(fr.datetime_landed)} ${fr.runway_landed ? `(ВПП ${fr.runway_landed})` : ''}</span>
|
||
</div>
|
||
</div>
|
||
<div class="status-info">
|
||
<div class="status-item"><span class="status-label">Время полёта:</span><span class="status-value">${formatDuration(fr.flight_time)}</span></div>
|
||
<div class="status-item"><span class="status-label">Фактическое расстояние:</span><span class="status-value">${formatDistance(fr.actual_distance)}</span></div>
|
||
<div class="status-item"><span class="status-label">Кратчайшее расстояние:</span><span class="status-value">${formatDistance(fr.circle_distance)}</span></div>
|
||
<div class="status-item"><span class="status-label">Статус полёта:</span><span class="status-value">${fr.flight_ended ? 'Завершён' : 'В процессе'}</span></div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
card += `</div>`;
|
||
return card;
|
||
};
|
||
|
||
// ================== HTML ==================
|
||
const now = new Date();
|
||
const reportDate = now.toLocaleString('ru-RU', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
|
||
const html = `<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Отчёт о рейсах</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.4; color: #333; background: #f5f5f5; padding: 15px; }
|
||
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||
.header { border-bottom: 3px solid #2563eb; padding-bottom: 8px; margin-bottom: 8px; }
|
||
.header h1 { color: #1e40af; font-size: 24px; margin-bottom: 4px; }
|
||
.header-meta { color: #666; font-size: 13px; }
|
||
.sources-info { display: flex; gap: 10px; margin-top: 4px; flex-wrap: wrap; }
|
||
.source-tag { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 500; }
|
||
.source-tag.available { background: #d1fae5; color: #065f46; }
|
||
.source-tag.unavailable { background: #fee2e2; color: #991b1b; }
|
||
.flight-card { border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 18px; overflow: hidden; background: white; }
|
||
.flight-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 18px; display: flex; justify-content: space-between; align-items: center; }
|
||
.flight-header h2 { font-size: 20px; margin: 0; }
|
||
.registration { background: rgba(255,255,255,0.2); padding: 4px 10px; border-radius: 4px; font-weight: 600; font-size: 13px; }
|
||
.flight-info { padding: 12px 18px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
|
||
.info-row { display: flex; margin-bottom: 6px; }
|
||
.info-row:last-child { margin-bottom: 0; }
|
||
.info-row .label { font-weight: 600; color: #4b5563; width: 140px; flex-shrink: 0; font-size: 13px; }
|
||
.info-row .value { color: #111827; font-size: 13px; }
|
||
.source-section { border-top: 1px solid #e5e7eb; padding: 12px 18px; }
|
||
.source-section:first-of-type { border-top: none; }
|
||
.source-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||
.source-badge { display: inline-block; padding: 5px 12px; border-radius: 5px; font-size: 12px; font-weight: 600; color: white; }
|
||
.source-badge.source-flightaware { background: #3b82f6; }
|
||
.source-badge.source-flightradar24 { background: #10b981; }
|
||
.source-missing { color: #ef4444; font-size: 12px; font-style: italic; }
|
||
.source-content { margin-left: 0; }
|
||
.route-info { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; padding: 12px; background: #f9fafb; border-radius: 6px; }
|
||
.route-item { display: flex; flex-direction: column; }
|
||
.route-label { font-size: 11px; color: #6b7280; margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||
.route-value { font-size: 14px; font-weight: 600; color: #111827; }
|
||
.timeline { margin-bottom: 12px; }
|
||
.timeline-item { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #e5e7eb; }
|
||
.timeline-item:last-child { border-bottom: none; }
|
||
.timeline-label { font-weight: 500; color: #4b5563; width: 160px; flex-shrink: 0; font-size: 12px; }
|
||
.timeline-value { text-align: right; font-size: 12px; }
|
||
.status-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; padding: 12px; background: #f9fafb; border-radius: 6px; }
|
||
.status-item { display: flex; flex-direction: column; }
|
||
.status-label { font-size: 11px; color: #6b7280; margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||
.status-value { font-size: 13px; font-weight: 600; color: #111827; }
|
||
.delay-negative { color: #10b981; }
|
||
.delay-positive { color: #ef4444; }
|
||
.flight-card.cancelled { border-color: #dc2626; }
|
||
.flight-card.cancelled .flight-header { background: linear-gradient(135deg, #b91c1c 0%, #7f1d1d 100%); }
|
||
.status-cancelled { display: block; padding: 10px 14px; margin-bottom: 12px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 6px; color: #b91c1c; font-weight: 700; font-size: 14px; }
|
||
.badge-cancelled { background: #dc2626 !important; color: white; padding: 4px 10px; border-radius: 4px; font-weight: 600; font-size: 13px; }
|
||
.no-data { text-align: center; padding: 40px 20px; color: #6b7280; font-size: 16px; }
|
||
@media print { body { background: white; padding: 0; } .container { box-shadow: none; padding: 15px; } .flight-card { page-break-inside: avoid; margin-bottom: 15px; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="header">
|
||
<h1>Отчёт о рейсах</h1>
|
||
<div class="header-meta">
|
||
<div>Дата формирования: ${reportDate}</div>
|
||
<div class="sources-info">
|
||
<span class="source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}">
|
||
FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
|
||
</span>
|
||
<span class="source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}">
|
||
FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flights-container">
|
||
${flights.length ? flights.map(f => generateFlightCard(f)).join('') : '<div class="no-data">Данные о рейсах не найдены</div>'}
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
|
||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||
|
||
return [{
|
||
json: {
|
||
html_base64: htmlBase64,
|
||
html: html,
|
||
flights_count: flights.length,
|
||
sources: {
|
||
flightaware: { available: flightAwareData.length > 0, count: flightAwareData.length },
|
||
flightradar24: { available: flightRadar24Data.length > 0, count: flightRadar24Data.length }
|
||
},
|
||
generated_at: now.toISOString()
|
||
}
|
||
}]; |