auto-sync: 2026-04-22 00:50:01
This commit is contained in:
@@ -462,19 +462,39 @@ def schedule_data():
|
||||
)
|
||||
total = total_row["cnt"] if total_row else 0
|
||||
|
||||
# Qualify bare column names that become ambiguous after JOIN
|
||||
import re as _re
|
||||
safe_where = _re.sub(r'\bflight_date\b', 's.flight_date', where)
|
||||
safe_where = _re.sub(r'\bflight_number\b', 's.flight_number', safe_where)
|
||||
|
||||
rows = query(
|
||||
f"""
|
||||
SELECT
|
||||
flight_number, airline_name, airport_iata, direction,
|
||||
origin_iata, destination_iata,
|
||||
scheduled_at, actual_at, status, icao24,
|
||||
flight_date, duration_min, thread_title,
|
||||
actual_takeoff, actual_landed,
|
||||
delay_takeoff_min, delay_landed_min,
|
||||
fr24_id, flight_category
|
||||
FROM fr24_ext.schedule
|
||||
WHERE {where}
|
||||
ORDER BY scheduled_at DESC
|
||||
s.flight_number, s.airline_name, s.airport_iata, s.direction,
|
||||
s.origin_iata, s.destination_iata,
|
||||
s.scheduled_at, s.actual_at, s.status, s.icao24,
|
||||
s.flight_date, s.duration_min, s.thread_title,
|
||||
s.actual_takeoff, s.actual_landed,
|
||||
s.delay_takeoff_min, s.delay_landed_min,
|
||||
s.fr24_id, s.flight_category, s.source AS sched_source,
|
||||
-- из flight_actual:
|
||||
fa.runway_takeoff, fa.runway_landed,
|
||||
fa.actual_distance,
|
||||
fa.flight_time AS fa_flight_time,
|
||||
fa.reg AS registration,
|
||||
fa.operated_as,
|
||||
fa.category AS fa_category,
|
||||
-- из mart.flights (тип ВС):
|
||||
mf.aircraft_type,
|
||||
mf.track_source, mf.track_points
|
||||
FROM fr24_ext.schedule s
|
||||
LEFT JOIN fr24_ext.flight_actual fa
|
||||
ON fa.fr24_id = s.fr24_id AND s.fr24_id IS NOT NULL
|
||||
LEFT JOIN fr24_mart.flights mf
|
||||
ON mf.flight_number = s.flight_number
|
||||
AND mf.flight_date = s.flight_date
|
||||
WHERE {safe_where}
|
||||
ORDER BY s.scheduled_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
params + [limit, offset],
|
||||
@@ -488,7 +508,6 @@ def schedule_data():
|
||||
int((actual - sched).total_seconds() / 60)
|
||||
if actual and sched else None
|
||||
)
|
||||
# Actual times from FR24 flight-summary/full enrichment
|
||||
actual_takeoff = r.get("actual_takeoff")
|
||||
actual_landed = r.get("actual_landed")
|
||||
delay_takeoff = r.get("delay_takeoff_min")
|
||||
@@ -512,13 +531,25 @@ def schedule_data():
|
||||
"duration_min": r["duration_min"],
|
||||
"status": r["status"],
|
||||
"icao24": r["icao24"],
|
||||
# New fields from FR24 enrichment
|
||||
# FR24 actual times
|
||||
"actual_takeoff": actual_takeoff.isoformat() if hasattr(actual_takeoff, 'isoformat') else None,
|
||||
"actual_landed": actual_landed.isoformat() if hasattr(actual_landed, 'isoformat') else None,
|
||||
"delay_takeoff_min": delay_takeoff,
|
||||
"delay_landed_min": delay_landed,
|
||||
"fr24_id": fr24_id,
|
||||
"fr24_id": fr24_id,
|
||||
"flight_category": flight_cat,
|
||||
# Новые поля из flight_actual
|
||||
"runway_takeoff": r.get("runway_takeoff"),
|
||||
"runway_landed": r.get("runway_landed"),
|
||||
"actual_distance": r.get("actual_distance"),
|
||||
"flight_time_min": r.get("fa_flight_time"),
|
||||
"registration": r.get("registration"),
|
||||
"aircraft_type": r.get("aircraft_type"),
|
||||
"track_source": r.get("track_source"),
|
||||
"track_points": r.get("track_points"),
|
||||
"sched_source": r.get("sched_source"),
|
||||
# Эффективная длительность: FA > schedule
|
||||
"duration_eff": r.get("fa_flight_time") or r.get("duration_min"),
|
||||
})
|
||||
|
||||
return ok({"total": total, "flights": flights})
|
||||
|
||||
@@ -178,6 +178,10 @@
|
||||
.cat-mil { background: #3d0c0c; color: #f85149; }
|
||||
.cat-other { background: #21262d; color: #8b949e; }
|
||||
|
||||
/* source badge */
|
||||
.src-badge { border-radius: 3px; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-left: 3px; vertical-align: middle; }
|
||||
.src-fr24 { background: #0d2d5e; color: #58a6ff; }
|
||||
|
||||
/* track link */
|
||||
.track-link {
|
||||
color: #58a6ff;
|
||||
@@ -322,19 +326,20 @@
|
||||
<th>Дата</th>
|
||||
<th>Рейс</th>
|
||||
<th>Авиакомпания</th>
|
||||
<th>Аэропорт</th>
|
||||
<th>Напр.</th>
|
||||
<th>↑↓</th>
|
||||
<th>Маршрут</th>
|
||||
<th>Запланировано</th>
|
||||
<th>Фактически</th>
|
||||
<th>Задержка</th>
|
||||
<th>Тип</th>
|
||||
<th>Статус</th>
|
||||
<th>Аэропорт</th>
|
||||
<th>По расп.</th>
|
||||
<th>Взлёт факт</th>
|
||||
<th>Посадка факт</th>
|
||||
<th>Длит.</th>
|
||||
<th>ВПП</th>
|
||||
<th>ВС</th>
|
||||
<th>Трек</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body">
|
||||
<tr><td colspan="12" class="state-msg">Загрузка…</td></tr>
|
||||
<tr><td colspan="13" class="state-msg">Загрузка…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -101,59 +101,93 @@ async function loadData() {
|
||||
}
|
||||
|
||||
// ── render table ──────────────────────────────────────────────────────────────
|
||||
// Колонки (13): Дата | Рейс | Авиакомпания | ↑↓ | Маршрут | Аэропорт |
|
||||
// По расп. | Взлёт факт | Посадка факт | Длит. | ВПП | ВС | Трек
|
||||
|
||||
function renderTable(flights) {
|
||||
const tbody = document.getElementById("table-body");
|
||||
if (!flights.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="12" class="state-msg">Нет данных по выбранным фильтрам</td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="13" class="state-msg">Нет данных по выбранным фильтрам</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = flights.map(f => {
|
||||
// 1. Дата
|
||||
const dateStr = fmtDateShort(f.scheduled_at);
|
||||
|
||||
// 2. Рейс — номер + cat badge + source badge
|
||||
const catBadge = categoryBadge(f.flight_category);
|
||||
const srcBadge = f.sched_source === "fr24"
|
||||
? `<span class="src-badge src-fr24" title="Источник: FR24 API">FR</span>`
|
||||
: "";
|
||||
const flightCell = `<strong>${esc(f.flight_number)}</strong>${catBadge}${srcBadge}`;
|
||||
|
||||
// 3. Авиакомпания + регистрация
|
||||
const airlineCell = esc(f.airline || "—")
|
||||
+ (f.registration ? `<br><small style="color:#6e7681">${esc(f.registration)}</small>` : "");
|
||||
|
||||
// 4. Направление
|
||||
const dirIcon = f.direction === "departure"
|
||||
? `<span class="dir-icon dir-dep" title="Вылет">↑</span>`
|
||||
: `<span class="dir-icon dir-arr" title="Прилёт">↓</span>`;
|
||||
|
||||
// 5. Маршрут
|
||||
const route = routeStr(f);
|
||||
|
||||
// 6. Аэропорт
|
||||
const airport = esc(f.airport);
|
||||
|
||||
// 7. По расписанию (MSK)
|
||||
const sched = fmtTime(f.scheduled_at);
|
||||
const actual = f.actual_at ? fmtTime(f.actual_at) : "—";
|
||||
const delay = delayCell(f.delay_min);
|
||||
const badge = statusBadge(f.status);
|
||||
const dateStr = fmtDateShort(f.scheduled_at);
|
||||
|
||||
// Actual times from FR24 enrichment
|
||||
const actTakeoff = f.actual_takeoff ? fmtTime(f.actual_takeoff) : "";
|
||||
const actLanded = f.actual_landed ? fmtTime(f.actual_landed) : "";
|
||||
const delayTakeoff = delayCell(f.delay_takeoff_min);
|
||||
const delayLanded = delayCell(f.delay_landed_min);
|
||||
// 8. Взлёт факт + задержка
|
||||
const actTakeoffCell = f.actual_takeoff
|
||||
? `<span class="act-time">${fmtTime(f.actual_takeoff)}</span> ${delayCell(f.delay_takeoff_min)}`
|
||||
: "—";
|
||||
|
||||
// Category badge
|
||||
const catBadge = categoryBadge(f.flight_category);
|
||||
// 9. Посадка факт
|
||||
const actLandedCell = f.actual_landed
|
||||
? `<span class="act-time">${fmtTime(f.actual_landed)}</span> ${delayCell(f.delay_landed_min)}`
|
||||
: "—";
|
||||
|
||||
// FR24 track link
|
||||
const trackLink = f.fr24_id
|
||||
? `<a href="https://www.flightradar24.com/data/flights/${f.fr24_id}" target="_blank" class="track-link" title="Трек FR24">✈</a>`
|
||||
: "";
|
||||
// 10. Длительность (FA flight_time > schedule duration_min)
|
||||
const durationCell = fmtDuration(f.duration_eff);
|
||||
|
||||
// Enriched actual time display: show actual takeoff/landed if available
|
||||
const actualDisplay = actTakeoff || actLanded
|
||||
? `<span class="act-time">${actTakeoff || "—"}</span>` +
|
||||
(f.direction === "arrival" && actLanded ? ` <span class="act-landed">⇣${actLanded}</span>` : "")
|
||||
: actual;
|
||||
// 11. ВПП
|
||||
const runway = (f.runway_takeoff || f.runway_landed)
|
||||
? `${f.runway_takeoff || "?"}→${f.runway_landed || "?"}`
|
||||
: "—";
|
||||
|
||||
// 12. Тип ВС
|
||||
const acType = f.aircraft_type ? esc(f.aircraft_type) : "—";
|
||||
|
||||
// 13. Трек: ✈ FR24 + 🛰 RTL-SDR + FA badge
|
||||
const trackCol = [
|
||||
f.fr24_id
|
||||
? `<a href="https://www.flightradar24.com/data/flights/${f.fr24_id}" target="_blank" class="track-link" title="Трек FR24">✈</a>`
|
||||
: "",
|
||||
f.track_source === "rtlsdr"
|
||||
? `<span title="${f.track_points || 0} точек RTL-SDR" style="color:#3fb950;cursor:default">🛰</span>`
|
||||
: "",
|
||||
f.track_source === "fa"
|
||||
? `<span title="${f.track_points || 0} точек FA" style="color:#d29922;cursor:default">FA</span>`
|
||||
: "",
|
||||
].filter(Boolean).join(" ") || "—";
|
||||
|
||||
return `<tr>
|
||||
<td>${dateStr}</td>
|
||||
<td><strong>${esc(f.flight_number)}</strong> ${trackLink}</td>
|
||||
<td>${esc(f.airline || "—")}</td>
|
||||
<td>${esc(f.airport)}</td>
|
||||
<td>${flightCell}</td>
|
||||
<td>${airlineCell}</td>
|
||||
<td>${dirIcon}</td>
|
||||
<td>${esc(route)}</td>
|
||||
<td>${airport}</td>
|
||||
<td>${sched}</td>
|
||||
<td>${actualDisplay}</td>
|
||||
<td>${delayTakeoff}</td>
|
||||
<td>${catBadge}</td>
|
||||
<td>${badge}</td>
|
||||
<td>${trackLink}</td>
|
||||
<td>${actTakeoffCell}</td>
|
||||
<td>${actLandedCell}</td>
|
||||
<td>${durationCell}</td>
|
||||
<td>${runway}</td>
|
||||
<td>${acType}</td>
|
||||
<td>${trackCol}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
}
|
||||
@@ -172,32 +206,62 @@ function renderCards(flights) {
|
||||
const dirClass = f.direction === "departure" ? "dir-dep" : "dir-arr";
|
||||
const route = routeStr(f);
|
||||
const sched = fmtTime(f.scheduled_at);
|
||||
const actual = f.actual_at ? fmtTime(f.actual_at) : "—";
|
||||
const delay = f.delay_min != null ? `${f.delay_min > 0 ? "+" : ""}${f.delay_min} мин` : "—";
|
||||
const badge = statusBadge(f.status);
|
||||
const catBadge = categoryBadge(f.flight_category);
|
||||
const trackLink = f.fr24_id
|
||||
? `<a href="https://www.flightradar24.com/data/flights/${f.fr24_id}" target="_blank" style="color:#58a6ff">Трек</a>`
|
||||
const srcBadge = f.sched_source === "fr24"
|
||||
? `<span class="src-badge src-fr24" title="Источник: FR24 API">FR</span>`
|
||||
: "";
|
||||
const actTakeoff = f.actual_takeoff ? fmtTime(f.actual_takeoff) : "";
|
||||
const actLanded = f.actual_landed ? fmtTime(f.actual_landed) : "";
|
||||
|
||||
// Track column for card
|
||||
const trackLinks = [
|
||||
f.fr24_id
|
||||
? `<a href="https://www.flightradar24.com/data/flights/${f.fr24_id}" target="_blank" style="color:#58a6ff">✈ FR24</a>`
|
||||
: "",
|
||||
f.track_source === "rtlsdr"
|
||||
? `<span title="${f.track_points || 0} точек RTL-SDR" style="color:#3fb950">🛰 RTL-SDR</span>`
|
||||
: "",
|
||||
f.track_source === "fa"
|
||||
? `<span title="${f.track_points || 0} точек FA" style="color:#d29922">FA трек</span>`
|
||||
: "",
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
const acInfo = [
|
||||
f.aircraft_type ? esc(f.aircraft_type) : "",
|
||||
f.registration ? `<span style="color:#6e7681">${esc(f.registration)}</span>` : "",
|
||||
].filter(Boolean).join(" / ");
|
||||
|
||||
const runway = (f.runway_takeoff || f.runway_landed)
|
||||
? `${f.runway_takeoff || "?"}→${f.runway_landed || "?"}`
|
||||
: "—";
|
||||
|
||||
return `<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="card-flight">${esc(f.flight_number)} ${catBadge} ${trackLink}</div>
|
||||
<div class="card-flight">${esc(f.flight_number)}${catBadge}${srcBadge}</div>
|
||||
<div class="card-airline">${esc(f.airline || "—")}</div>
|
||||
</div>
|
||||
${badge}
|
||||
</div>
|
||||
<div class="card-row"><span>Аэропорт</span><span>${esc(f.airport)}</span></div>
|
||||
<div class="card-row"><span>Направление</span><span class="${dirClass}">${dirLabel}</span></div>
|
||||
<div class="card-row"><span>Маршрут</span><span>${esc(route)}</span></div>
|
||||
<div class="card-row"><span>Запланировано</span><span>${sched}</span></div>
|
||||
<div class="card-row"><span>Фактически</span><span>${actual}</span></div>
|
||||
<div class="card-row"><span>Задержка</span><span>${delay}</span></div>
|
||||
${actTakeoff ? `<div class="card-row"><span>Взлёт факт</span><span>${actTakeoff} ${delayCell(f.delay_takeoff_min)}</span></div>` : ""}
|
||||
${actLanded ? `<div class="card-row"><span>Посадка факт</span><span>${actLanded} ${delayCell(f.delay_landed_min)}</span></div>` : ""}
|
||||
<div class="card-row"><span>По расписанию</span><span>${sched}</span></div>
|
||||
${f.actual_takeoff
|
||||
? `<div class="card-row"><span>Взлёт факт</span><span>${fmtTime(f.actual_takeoff)} ${delayCell(f.delay_takeoff_min)}</span></div>`
|
||||
: ""}
|
||||
${f.actual_landed
|
||||
? `<div class="card-row"><span>Посадка факт</span><span>${fmtTime(f.actual_landed)} ${delayCell(f.delay_landed_min)}</span></div>`
|
||||
: ""}
|
||||
${f.duration_eff
|
||||
? `<div class="card-row"><span>Длительность</span><span>${fmtDuration(f.duration_eff)}</span></div>`
|
||||
: ""}
|
||||
${runway !== "—"
|
||||
? `<div class="card-row"><span>ВПП</span><span>${runway}</span></div>`
|
||||
: ""}
|
||||
${acInfo
|
||||
? `<div class="card-row"><span>ВС / Борт</span><span>${acInfo}</span></div>`
|
||||
: ""}
|
||||
${trackLinks
|
||||
? `<div class="card-row"><span>Трек</span><span>${trackLinks}</span></div>`
|
||||
: ""}
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
@@ -214,10 +278,7 @@ function exportCsv() {
|
||||
function routeStr(f) {
|
||||
// Prefer thread_title from Yandex (most accurate)
|
||||
const tt = f.thread_title || "";
|
||||
if (tt) {
|
||||
// "Москва — Алматы" → for departure show destination, for arrival show origin
|
||||
return tt;
|
||||
}
|
||||
if (tt) return tt;
|
||||
const o = f.origin || "";
|
||||
const d = f.destination || "";
|
||||
if (!o && !d) return "—";
|
||||
@@ -226,6 +287,13 @@ function routeStr(f) {
|
||||
return `${o} → ${d}`;
|
||||
}
|
||||
|
||||
function fmtDuration(min) {
|
||||
if (!min) return "—";
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
return h > 0 ? `${h}ч ${m}м` : `${m}м`;
|
||||
}
|
||||
|
||||
function categoryBadge(category) {
|
||||
if (!category) return "";
|
||||
const cls = {
|
||||
@@ -241,30 +309,11 @@ function categoryBadge(category) {
|
||||
return `<span class="cat-badge ${cls}" title="${category}">${labels[category] || category}</span>`;
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const map = {
|
||||
scheduled: "badge-scheduled",
|
||||
departed: "badge-departed",
|
||||
arrived: "badge-arrived",
|
||||
delayed: "badge-delayed",
|
||||
cancelled: "badge-cancelled",
|
||||
};
|
||||
const cls = map[status] || "badge-scheduled";
|
||||
const labels = {
|
||||
scheduled: "По расписанию",
|
||||
departed: "Вылетел",
|
||||
arrived: "Прилетел",
|
||||
delayed: "Задержан",
|
||||
cancelled: "Отменён",
|
||||
};
|
||||
return `<span class="badge ${cls}">${labels[status] || status || "—"}</span>`;
|
||||
}
|
||||
|
||||
function delayCell(min) {
|
||||
if (min == null) return "—";
|
||||
if (min == null) return "";
|
||||
if (min > 30) return `<span class="delay-critical">+${min}</span>`;
|
||||
if (min > 0) return `<span class="delay-pos">+${min}</span>`;
|
||||
if (min < 0) return `<span class="delay-neg">${min}</span>`;
|
||||
if (min > 0) return `<span class="delay-pos">+${min}</span>`;
|
||||
if (min < 0) return `<span class="delay-neg">${min}</span>`;
|
||||
return `<span class="delay-ok">0</span>`;
|
||||
}
|
||||
|
||||
@@ -295,7 +344,7 @@ function esc(s) {
|
||||
function setLoading(on) {
|
||||
if (on) {
|
||||
document.getElementById("table-body").innerHTML =
|
||||
`<tr><td colspan="12" class="state-msg">Загрузка…</td></tr>`;
|
||||
`<tr><td colspan="13" class="state-msg">Загрузка…</td></tr>`;
|
||||
document.getElementById("cards-container").innerHTML =
|
||||
`<div class="state-msg">Загрузка…</div>`;
|
||||
}
|
||||
@@ -303,7 +352,7 @@ function setLoading(on) {
|
||||
|
||||
function showError(msg) {
|
||||
document.getElementById("table-body").innerHTML =
|
||||
`<tr><td colspan="12" class="state-msg" style="color:#f85149">Ошибка: ${esc(msg)}</td></tr>`;
|
||||
`<tr><td colspan="13" class="state-msg" style="color:#f85149">Ошибка: ${esc(msg)}</td></tr>`;
|
||||
document.getElementById("cards-container").innerHTML =
|
||||
`<div class="state-msg" style="color:#f85149">Ошибка: ${esc(msg)}</div>`;
|
||||
}
|
||||
|
||||
@@ -59,22 +59,34 @@ def _build_airports_param() -> str:
|
||||
|
||||
|
||||
def fetch_flight_summaries(target_date: date) -> List[Dict]:
|
||||
"""Fetch all flights from flight-summary/full for a single day."""
|
||||
"""Fetch all flights from flight-summary/full for a single day.
|
||||
Explorer tier returns max 20 results per query — paginate with offset."""
|
||||
dt_from = f"{target_date}T00:00:00"
|
||||
dt_to = f"{target_date}T23:59:59"
|
||||
airports_param = _build_airports_param()
|
||||
PAGE = 20 # Explorer tier hard limit per request
|
||||
|
||||
data = _get("/api/flight-summary/full", params={
|
||||
"flight_datetime_from": dt_from,
|
||||
"flight_datetime_to": dt_to,
|
||||
"airports": airports_param,
|
||||
"limit": config.PAGE_SIZE,
|
||||
})
|
||||
all_items: List[Dict] = []
|
||||
offset = 0
|
||||
while True:
|
||||
data = _get("/api/flight-summary/full", params={
|
||||
"flight_datetime_from": dt_from,
|
||||
"flight_datetime_to": dt_to,
|
||||
"airports": airports_param,
|
||||
"limit": PAGE,
|
||||
"offset": offset,
|
||||
})
|
||||
items = data.get("data", data) if isinstance(data, dict) else data
|
||||
if not items or not isinstance(items, list):
|
||||
break
|
||||
all_items.extend(items)
|
||||
log.debug("fetch_flight_summaries: offset=%d got %d, total so far %d",
|
||||
offset, len(items), len(all_items))
|
||||
if len(items) < PAGE:
|
||||
break # last page
|
||||
offset += PAGE
|
||||
|
||||
items = data.get("data", data) if isinstance(data, dict) else data
|
||||
if not items:
|
||||
return []
|
||||
return items if isinstance(items, list) else []
|
||||
return all_items
|
||||
|
||||
|
||||
def fetch_track(fr24_id: str) -> Optional[List[Dict]]:
|
||||
|
||||
199
tasks/flightradar24/reports/TZ-schedule-ui-enrichment.md
Normal file
199
tasks/flightradar24/reports/TZ-schedule-ui-enrichment.md
Normal file
@@ -0,0 +1,199 @@
|
||||
# ТЗ: Обогащение UI расписания данными FR24
|
||||
|
||||
**Дата:** 2026-04-21
|
||||
**Статус:** READY FOR DEV
|
||||
**Файлы:** `frontend/main.py`, `frontend/static/schedule.html`, `frontend/static/schedule.js`
|
||||
|
||||
---
|
||||
|
||||
## Что сейчас есть и что не так
|
||||
|
||||
Таблица `/schedule` уже получает часть FR24-полей (actual_takeoff, delay, fr24_id, flight_category),
|
||||
но отображение плохое:
|
||||
- Трек-ссылка ✈ дублируется в столбцах 2 и 12
|
||||
- `duration_min` есть в API, но не отображается в таблице
|
||||
- Runway, дистанция, тип ВС, регистрация — не показываются
|
||||
- Колонка "Фактически" показывает `actual_at` из Яндекса (часто null), а не FR24 actual_takeoff
|
||||
- Нет признака источника данных (Яндекс vs FR24)
|
||||
|
||||
---
|
||||
|
||||
## Изменения в `main.py`
|
||||
|
||||
### 1. Обогатить `/api/schedule/data` данными из `flight_actual`
|
||||
|
||||
Добавить LEFT JOIN на `fr24_ext.flight_actual` в запрос:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
s.flight_number, s.airline_name, s.airport_iata, s.direction,
|
||||
s.origin_iata, s.destination_iata,
|
||||
s.scheduled_at, s.actual_at, s.status,
|
||||
s.flight_date, s.duration_min, s.thread_title,
|
||||
s.actual_takeoff, s.actual_landed,
|
||||
s.delay_takeoff_min, s.delay_landed_min,
|
||||
s.fr24_id, s.flight_category, s.source AS sched_source,
|
||||
-- из flight_actual:
|
||||
fa.runway_takeoff, fa.runway_landed,
|
||||
fa.actual_distance,
|
||||
fa.flight_time AS fa_flight_time,
|
||||
fa.reg AS registration,
|
||||
fa.operated_as,
|
||||
fa.category AS fa_category,
|
||||
-- из mart.flights (тип ВС):
|
||||
mf.aircraft_type,
|
||||
mf.track_source, mf.track_points
|
||||
FROM fr24_ext.schedule s
|
||||
LEFT JOIN fr24_ext.flight_actual fa
|
||||
ON fa.fr24_id = s.fr24_id AND s.fr24_id IS NOT NULL
|
||||
LEFT JOIN fr24_mart.flights mf
|
||||
ON mf.flight_number = s.flight_number
|
||||
AND mf.flight_date = s.flight_date
|
||||
WHERE {where}
|
||||
ORDER BY s.scheduled_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
```
|
||||
|
||||
### 2. Добавить новые поля в JSON-ответ
|
||||
|
||||
В `schedule_data()` добавить в dict полёта:
|
||||
```python
|
||||
"runway_takeoff": r.get("runway_takeoff"),
|
||||
"runway_landed": r.get("runway_landed"),
|
||||
"actual_distance": r.get("actual_distance"), # км
|
||||
"flight_time_min": r.get("fa_flight_time"), # минуты из FA
|
||||
"registration": r.get("registration"),
|
||||
"aircraft_type": r.get("aircraft_type"),
|
||||
"track_source": r.get("track_source"), # rtlsdr/fr24/fa/null
|
||||
"track_points": r.get("track_points"),
|
||||
"sched_source": r.get("sched_source"), # yandex/fr24
|
||||
```
|
||||
|
||||
Эффективная длительность:
|
||||
```python
|
||||
"duration_eff": r.get("fa_flight_time") or r.get("duration_min"),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Изменения в `schedule.html`
|
||||
|
||||
### Новые колонки таблицы (вместо текущих 12)
|
||||
|
||||
| # | Колонка | Данные |
|
||||
|---|---------|--------|
|
||||
| 1 | Дата | flight_date |
|
||||
| 2 | Рейс | flight_number + cat badge + source badge |
|
||||
| 3 | Авиакомпания | airline + registration (мелко) |
|
||||
| 4 | ↑↓ | direction |
|
||||
| 5 | Маршрут | thread_title или origin → destination |
|
||||
| 6 | Аэропорт | airport_iata |
|
||||
| 7 | По расп. | scheduled_at (MSK) |
|
||||
| 8 | Взлёт факт | actual_takeoff (MSK) + delay badge |
|
||||
| 9 | Посадка факт | actual_landed (MSK) |
|
||||
| 10 | Длит. | duration_eff в формате "2ч 35м" |
|
||||
| 11 | ВПП | runway_takeoff → runway_landed |
|
||||
| 12 | ВС | aircraft_type |
|
||||
| 13 | Трек | ✈ ссылка на FR24 + 🛰 если есть RTL-SDR трек |
|
||||
|
||||
**Убрать:** дублирующий столбец Трек (#12 старый), убрать Статус (всегда scheduled).
|
||||
|
||||
### Source badge в колонке Рейс
|
||||
|
||||
```html
|
||||
<!-- если sched_source == 'fr24' -->
|
||||
<span class="src-badge src-fr24" title="Данные из FR24 API">FR</span>
|
||||
<!-- если sched_source == 'yandex' -->
|
||||
<!-- не показываем ничего, это норма -->
|
||||
```
|
||||
|
||||
CSS для src-badge:
|
||||
```css
|
||||
.src-badge { border-radius:3px; font-size:9px; font-weight:700; padding:1px 4px; margin-left:3px; }
|
||||
.src-fr24 { background:#0d2d5e; color:#58a6ff; }
|
||||
```
|
||||
|
||||
### Track source badge в колонке Трек
|
||||
|
||||
```js
|
||||
// ✈ = ссылка на FR24
|
||||
// 🛰 = RTL-SDR трек есть в mart
|
||||
const rtlBadge = f.track_source === 'rtlsdr'
|
||||
? `<span title="${f.track_points} точек RTL-SDR" style="color:#3fb950;cursor:default">🛰</span>`
|
||||
: '';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Изменения в `schedule.js`
|
||||
|
||||
### `renderTable()` — новые колонки
|
||||
|
||||
```js
|
||||
// Длительность
|
||||
function fmtDuration(min) {
|
||||
if (!min) return "—";
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
return h > 0 ? `${h}ч ${m}м` : `${m}м`;
|
||||
}
|
||||
|
||||
// ВПП
|
||||
const runway = (f.runway_takeoff || f.runway_landed)
|
||||
? `${f.runway_takeoff || "?"}→${f.runway_landed || "?"}`
|
||||
: "—";
|
||||
|
||||
// Actual takeoff с задержкой
|
||||
const actTakeoffCell = f.actual_takeoff
|
||||
? `${fmtTime(f.actual_takeoff)} ${delayCell(f.delay_takeoff_min)}`
|
||||
: "—";
|
||||
|
||||
// Actual landed
|
||||
const actLandedCell = f.actual_landed
|
||||
? fmtTime(f.actual_landed)
|
||||
: "—";
|
||||
|
||||
// Регистрация под авиакомпанией
|
||||
const airlineCell = `${esc(f.airline || "—")}`
|
||||
+ (f.registration ? `<br><small style="color:#6e7681">${esc(f.registration)}</small>` : "");
|
||||
|
||||
// Source badge
|
||||
const srcBadge = f.sched_source === 'fr24'
|
||||
? `<span class="src-badge src-fr24" title="Источник: FR24">FR</span>` : "";
|
||||
|
||||
// Track column
|
||||
const trackCol = [
|
||||
f.fr24_id ? `<a href="https://www.flightradar24.com/data/flights/${f.fr24_id}" target="_blank" class="track-link" title="Трек FR24">✈</a>` : "",
|
||||
f.track_source === 'rtlsdr' ? `<span title="${f.track_points||0} точек RTL-SDR" style="color:#3fb950">🛰</span>` : "",
|
||||
f.track_source === 'fa' ? `<span title="${f.track_points||0} точек FA" style="color:#d29922">FA</span>` : "",
|
||||
].filter(Boolean).join(" ");
|
||||
```
|
||||
|
||||
### `renderCards()` — добавить новые поля
|
||||
|
||||
В мобильных карточках добавить строки:
|
||||
- Взлёт факт + задержка
|
||||
- Посадка факт
|
||||
- Длительность
|
||||
- ВПП
|
||||
- Тип ВС / Регистрация
|
||||
- Трек (FR24 + RTL-SDR badge)
|
||||
|
||||
---
|
||||
|
||||
## Деплой
|
||||
|
||||
Файлы на VM лежат в контейнере `fr24-api`, монтируются из:
|
||||
- `/home/fr24/projects/fr24/frontend/main.py`
|
||||
- `/home/fr24/projects/fr24/frontend/static/schedule.html`
|
||||
- `/home/fr24/projects/fr24/frontend/static/schedule.js`
|
||||
|
||||
После изменений: `docker restart fr24-api`
|
||||
|
||||
---
|
||||
|
||||
## НЕ делать
|
||||
|
||||
- Не трогать другие страницы (index.html, monitoring.html, data_sources)
|
||||
- Не менять схему БД
|
||||
- Не трогать main.py кроме функции `schedule_data()` и добавления SQL JOIN
|
||||
124
tasks/flightradar24/reports/dev-2026-04-21-schedule-ui.md
Normal file
124
tasks/flightradar24/reports/dev-2026-04-21-schedule-ui.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# dev-2026-04-21-schedule-ui.md
|
||||
|
||||
**Дата:** 2026-04-21
|
||||
**Статус:** DONE
|
||||
**ТЗ:** TZ-schedule-ui-enrichment.md
|
||||
|
||||
---
|
||||
|
||||
## Задача
|
||||
|
||||
Обогащение UI расписания данными из `flight_actual` и `mart.flights`:
|
||||
- Новые колонки: ВПП, тип ВС, регистрация, длительность, взлёт/посадка факт
|
||||
- Source badge для FR24-источников
|
||||
- Track badge для RTL-SDR/FA треков
|
||||
- Убрать дублирующийся столбец Трек и столбец Статус
|
||||
|
||||
---
|
||||
|
||||
## Выполненные изменения
|
||||
|
||||
### `main.py` — функция `schedule_data()`
|
||||
|
||||
1. **SQL переписан** с bare `FROM fr24_ext.schedule` на `FROM fr24_ext.schedule s`
|
||||
с двумя LEFT JOIN:
|
||||
- `LEFT JOIN fr24_ext.flight_actual fa ON fa.fr24_id = s.fr24_id AND s.fr24_id IS NOT NULL`
|
||||
- `LEFT JOIN fr24_mart.flights mf ON mf.flight_number = s.flight_number AND mf.flight_date = s.flight_date`
|
||||
|
||||
2. **Добавлены новые SELECT поля:**
|
||||
- `fa.runway_takeoff`, `fa.runway_landed`
|
||||
- `fa.actual_distance`
|
||||
- `fa.flight_time AS fa_flight_time`
|
||||
- `fa.reg AS registration`
|
||||
- `fa.operated_as`
|
||||
- `fa.category AS fa_category`
|
||||
- `mf.aircraft_type`
|
||||
- `mf.track_source`, `mf.track_points`
|
||||
- `s.source AS sched_source`
|
||||
|
||||
3. **Фикс ambiguity**: `_schedule_where()` генерирует WHERE без table-alias.
|
||||
После JOIN столбцы `flight_date` и `flight_number` становятся неоднозначными
|
||||
(есть в `flight_actual` и `mart.flights`).
|
||||
Решение: в `schedule_data()` применяется `re.sub()` для подстановки `s.` prefix
|
||||
перед этими колонками в safe_where строке.
|
||||
|
||||
4. **Новые поля в JSON-ответе:**
|
||||
- `runway_takeoff`, `runway_landed`
|
||||
- `actual_distance`
|
||||
- `flight_time_min` (из fa_flight_time)
|
||||
- `registration`
|
||||
- `aircraft_type`
|
||||
- `track_source`, `track_points`
|
||||
- `sched_source`
|
||||
- `duration_eff` = `fa_flight_time or duration_min`
|
||||
|
||||
---
|
||||
|
||||
### `schedule.html` — полная перепись
|
||||
|
||||
**Было:** 12 колонок, Трек дублировался в col 2 и col 12, был столбец Статус
|
||||
**Стало:** 13 колонок согласно ТЗ:
|
||||
|
||||
| # | Колонка | Данные |
|
||||
|---|---------|--------|
|
||||
| 1 | Дата | flight_date |
|
||||
| 2 | Рейс | flight_number + cat badge + source badge |
|
||||
| 3 | Авиакомпания | airline + registration (мелко) |
|
||||
| 4 | ↑↓ | direction icon |
|
||||
| 5 | Маршрут | thread_title или origin → destination |
|
||||
| 6 | Аэропорт | airport_iata |
|
||||
| 7 | По расп. | scheduled_at (MSK) |
|
||||
| 8 | Взлёт факт | actual_takeoff (MSK) + delay badge |
|
||||
| 9 | Посадка факт | actual_landed (MSK) + delay badge |
|
||||
| 10 | Длит. | duration_eff в формате "2ч 35м" |
|
||||
| 11 | ВПП | runway_takeoff → runway_landed |
|
||||
| 12 | ВС | aircraft_type |
|
||||
| 13 | Трек | ✈ FR24 + 🛰 RTL-SDR + FA badge |
|
||||
|
||||
**Добавлен CSS:**
|
||||
- `.src-badge` + `.src-fr24` для badge источника данных
|
||||
|
||||
---
|
||||
|
||||
### `schedule.js` — полная перепись
|
||||
|
||||
**Новые функции:**
|
||||
- `fmtDuration(min)` — форматирует минуты как "2ч 35м" или "45м"
|
||||
|
||||
**`renderTable()` переписана:**
|
||||
- 13 колонок вместо 12
|
||||
- `actTakeoffCell`: actual_takeoff + delayCell(delay_takeoff_min)
|
||||
- `actLandedCell`: actual_landed + delayCell(delay_landed_min)
|
||||
- `airlineCell`: airline + `<small>registration</small>` под ней
|
||||
- `srcBadge`: показывается если `sched_source === 'fr24'`
|
||||
- `trackCol`: ✈ (если fr24_id) + 🛰 (если track_source=rtlsdr) + FA (если track_source=fa)
|
||||
- `runway`: `runway_takeoff → runway_landed` или "—"
|
||||
- `acType`: aircraft_type
|
||||
- `durationCell`: fmtDuration(duration_eff)
|
||||
|
||||
**`renderCards()` обновлена** — добавлены строки:
|
||||
- Взлёт факт + задержка
|
||||
- Посадка факт + задержка
|
||||
- Длительность
|
||||
- ВПП
|
||||
- ВС / Борт (aircraft_type / registration)
|
||||
- Трек (✈ FR24, 🛰 RTL-SDR, FA)
|
||||
|
||||
**`delayCell()`** — убран возврат "—" для null (теперь возвращает пустую строку,
|
||||
чтобы не засорять ячейки когда задержки нет)
|
||||
|
||||
**colspan** везде обновлён с 12 на 13 (setLoading, showError, empty state)
|
||||
|
||||
---
|
||||
|
||||
## Деплой
|
||||
|
||||
Файлы нужно скопировать на VM в контейнер `fr24-api`:
|
||||
```
|
||||
/home/fr24/projects/fr24/frontend/main.py
|
||||
/home/fr24/projects/fr24/frontend/static/schedule.html
|
||||
/home/fr24/projects/fr24/frontend/static/schedule.js
|
||||
```
|
||||
После: `docker restart fr24-api`
|
||||
|
||||
---
|
||||
Reference in New Issue
Block a user