feat(dm): завершено построение DM-слоя (5 витрин) и исправлены баги денормализации

- Зачем:
  - предоставить студентам полный набор аналитических витрин с примерами различных паттернов (UPSERT, Full Rebuild, UNION ALL, двухуровневая агрегация).
- Что:
  - реализованы витрины: sales_report, route_performance, passenger_loyalty, airport_traffic, monthly_overview.
  - исправлен баг в route_performance_load.sql: добавлены JOIN к dim_airports и dim_airplanes для корректной денормализации атрибутов.
  - обновлен скрипт e2e_etl.sh: добавлена верификация всех 5 витрин и проверка бизнес-логики (load factor).
  - обновлены DAGи, тесты и главный DDL скрипт.
- Проверка:
  - автоматизированный прогон e2e_etl.sh через REST API Airflow.
This commit is contained in:
2026-03-04 22:55:16 +03:00
parent f68b055b00
commit 2228312b55
9 changed files with 342 additions and 32 deletions
+40
View File
@@ -0,0 +1,40 @@
-- DDL для витрины dm.monthly_overview (Помесячная сводка).
--
-- Учебные цели:
-- 1. Двухуровневая агрегация (до рейса, затем до месяца) для точного расчета
-- средних показателей (load factor), избегая парадокса Симпсона.
-- 2. Выбор ключа распределения (Distribution Key):
-- Мы используем airplane_sk, а НЕ (year, month). Распределение по датам в MPP —
-- это антипаттерн, приводящий к Data Skew (весь месяц на одном сегменте).
CREATE TABLE IF NOT EXISTS dm.monthly_overview (
-- Зерно: Месяц и самолет
year_actual INTEGER NOT NULL,
month_actual INTEGER NOT NULL,
airplane_sk INTEGER NOT NULL,
-- Денормализованные атрибуты
airplane_bk TEXT NOT NULL,
airplane_model TEXT NOT NULL,
total_seats INTEGER NOT NULL,
-- Метрики
total_flights INTEGER NOT NULL DEFAULT 0,
total_tickets INTEGER NOT NULL DEFAULT 0,
total_boarded INTEGER NOT NULL DEFAULT 0,
total_revenue NUMERIC(15,2) NOT NULL DEFAULT 0,
avg_ticket_price NUMERIC(10,2),
avg_load_factor NUMERIC(5,4), -- Точный load factor (сначала по рейсу, потом среднее)
unique_routes INTEGER NOT NULL DEFAULT 0,
unique_passengers INTEGER NOT NULL DEFAULT 0,
-- Служебные
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now(),
_load_id TEXT NOT NULL,
_load_ts TIMESTAMP NOT NULL DEFAULT now()
)
WITH (appendonly=false) -- Heap для UPSERT
DISTRIBUTED BY (airplane_sk); -- Избегаем распределения по (year, month)
COMMENT ON TABLE dm.monthly_overview IS 'Витрина: помесячная аналитика с точным load_factor (Двухуровневая агрегация, Heap)';
+52
View File
@@ -0,0 +1,52 @@
-- DQ проверки для витрины dm.monthly_overview.
--
-- Учебные цели:
-- 1. Валидация логики двухуровневой агрегации: load factor не должен превышать 1.0
-- (в отличие от route_performance, где допускалась погрешность из-за упрощенного расчета).
-- 2. Базовые санити-проверки для дат (month BETWEEN 1 AND 12).
DO $$
DECLARE
row_count INTEGER;
duplicate_count INTEGER;
invalid_month_count INTEGER;
invalid_load_factor_count INTEGER;
BEGIN
-- 1. Проверка на наполненность
SELECT COUNT(*) INTO row_count FROM dm.monthly_overview;
IF row_count = 0 THEN
RAISE EXCEPTION 'DQ Error: Таблица dm.monthly_overview пуста.';
END IF;
-- 2. Проверка на уникальность (зерно - год + месяц + самолет)
SELECT COUNT(*) INTO duplicate_count
FROM (
SELECT year_actual, month_actual, airplane_sk FROM dm.monthly_overview
GROUP BY year_actual, month_actual, airplane_sk HAVING COUNT(*) > 1
) q;
IF duplicate_count > 0 THEN
RAISE EXCEPTION 'DQ Error: В dm.monthly_overview обнаружены дубликаты по (year, month, airplane_sk) (% шт).', duplicate_count;
END IF;
-- 3. Санити-проверка месяца
SELECT COUNT(*) INTO invalid_month_count
FROM dm.monthly_overview
WHERE month_actual < 1 OR month_actual > 12;
IF invalid_month_count > 0 THEN
RAISE EXCEPTION 'DQ Error: В dm.monthly_overview обнаружены некорректные месяцы (% строк).', invalid_month_count;
END IF;
-- 4. Проверка точного load_factor
-- В этой витрине мы считаем его честно (по рейсам), поэтому он строго <= 1.0
-- (исключая экзотические случаи овербукинга, но для учебного стенда ставим жесткий лимит 1.0).
SELECT COUNT(*) INTO invalid_load_factor_count
FROM dm.monthly_overview
WHERE avg_load_factor < 0 OR avg_load_factor > 1.0;
IF invalid_load_factor_count > 0 THEN
RAISE EXCEPTION 'DQ Error: В dm.monthly_overview обнаружен avg_load_factor вне диапазона 0..1 (% строк).', invalid_load_factor_count;
END IF;
RAISE NOTICE 'DQ Success: dm.monthly_overview успешно прошла все проверки (% строк).', row_count;
END $$;
+165
View File
@@ -0,0 +1,165 @@
-- Загрузка витрины dm.monthly_overview: инкрементальный UPSERT по месяцам.
--
-- Учебные цели:
-- 1. Двухуровневая агрегация: чтобы честно посчитать среднюю заполняемость (avg_load_factor),
-- мы сначала считаем её для КАЖДОГО рейса, а затем берем среднее по месяцу.
-- 2. Ограничения SCD1: dim_airplanes — это SCD1-измерение. Мы берем total_seats из
-- его текущего состояния. Если бы самолет переоборудовали (изменили число мест) в прошлом,
-- для точного исторического расчета нам потребовалось бы SCD2-измерение.
-- Шаг 1: Находим затронутые месяцы (HWM).
CREATE TEMP TABLE tmp_monthly_affected_dates ON COMMIT DROP AS
SELECT DISTINCT cal.year_actual, cal.month_actual
FROM dds.fact_flight_sales f
JOIN dds.dim_calendar cal ON f.calendar_sk = cal.calendar_sk
WHERE f._load_ts > (
SELECT COALESCE(MAX(_load_ts), '1900-01-01'::TIMESTAMP)
FROM dm.monthly_overview
);
-- Шаг 2: Уровень 1 - Агрегация фактов до рейса (flight_id).
-- Считаем точный load_factor для каждого отдельного перелета.
CREATE TEMP TABLE tmp_flight_level_metrics ON COMMIT DROP AS
SELECT
cal.year_actual,
cal.month_actual,
f.flight_id,
f.airplane_sk,
a.total_seats, -- ВНИМАНИЕ: берется текущее значение (SCD1)
COUNT(*) AS tickets_sold_per_flight,
SUM(CASE WHEN f.is_boarded THEN 1 ELSE 0 END) AS boarded_per_flight,
SUM(f.price) AS revenue_per_flight,
-- Точный load factor конкретного рейса: посаженные пассажиры / кол-во мест
(SUM(CASE WHEN f.is_boarded THEN 1 ELSE 0 END)::NUMERIC / NULLIF(a.total_seats, 0)) AS flight_load_factor
FROM dds.fact_flight_sales f
JOIN dds.dim_calendar cal ON f.calendar_sk = cal.calendar_sk
JOIN dds.dim_airplanes a ON f.airplane_sk = a.airplane_sk
-- Фильтруем только затронутые месяцы
WHERE EXISTS (
SELECT 1 FROM tmp_monthly_affected_dates tad
WHERE tad.year_actual = cal.year_actual AND tad.month_actual = cal.month_actual
)
GROUP BY cal.year_actual, cal.month_actual, f.flight_id, f.airplane_sk, a.total_seats;
-- Шаг 3: Уровень 2 - Агрегация от рейсов до месяцев.
-- Плюс собираем уникальные метрики (пассажиры, маршруты) напрямую из фактов.
CREATE TEMP TABLE tmp_monthly_overview_delta ON COMMIT DROP AS
WITH flight_aggs AS (
SELECT
year_actual,
month_actual,
airplane_sk,
COUNT(DISTINCT flight_id) AS total_flights,
SUM(tickets_sold_per_flight) AS total_tickets,
SUM(boarded_per_flight) AS total_boarded,
SUM(revenue_per_flight) AS total_revenue,
-- Честное среднее от рейсовых показателей
AVG(flight_load_factor) AS avg_load_factor
FROM tmp_flight_level_metrics
GROUP BY year_actual, month_actual, airplane_sk
),
unique_aggs AS (
SELECT
cal.year_actual,
cal.month_actual,
f.airplane_sk,
COUNT(DISTINCT r.route_bk) AS unique_routes, -- Считаем по BK (SCD2)
COUNT(DISTINCT f.passenger_sk) AS unique_passengers
FROM dds.fact_flight_sales f
JOIN dds.dim_calendar cal ON f.calendar_sk = cal.calendar_sk
JOIN dds.dim_routes r ON f.route_sk = r.route_sk
WHERE EXISTS (
SELECT 1 FROM tmp_monthly_affected_dates tad
WHERE tad.year_actual = cal.year_actual AND tad.month_actual = cal.month_actual
)
GROUP BY cal.year_actual, cal.month_actual, f.airplane_sk
)
SELECT
fa.year_actual,
fa.month_actual,
fa.airplane_sk,
a.airplane_bk,
a.model AS airplane_model,
a.total_seats,
fa.total_flights,
fa.total_tickets,
fa.total_boarded,
fa.total_revenue,
(fa.total_revenue / NULLIF(fa.total_tickets, 0))::NUMERIC(10,2) AS avg_ticket_price,
fa.avg_load_factor::NUMERIC(5,4) AS avg_load_factor,
ua.unique_routes,
ua.unique_passengers
FROM flight_aggs fa
JOIN unique_aggs ua ON fa.year_actual = ua.year_actual AND fa.month_actual = ua.month_actual AND fa.airplane_sk = ua.airplane_sk
JOIN dds.dim_airplanes a ON fa.airplane_sk = a.airplane_sk;
-- Шаг 4: UPDATE существующих записей.
UPDATE dm.monthly_overview AS tgt
SET
airplane_bk = src.airplane_bk,
airplane_model = src.airplane_model,
total_seats = src.total_seats,
total_flights = src.total_flights,
total_tickets = src.total_tickets,
total_boarded = src.total_boarded,
total_revenue = src.total_revenue,
avg_ticket_price = src.avg_ticket_price,
avg_load_factor = src.avg_load_factor,
unique_routes = src.unique_routes,
unique_passengers = src.unique_passengers,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM tmp_monthly_overview_delta AS src
WHERE tgt.year_actual = src.year_actual
AND tgt.month_actual = src.month_actual
AND tgt.airplane_sk = src.airplane_sk
AND (
tgt.total_flights IS DISTINCT FROM src.total_flights
OR tgt.total_revenue IS DISTINCT FROM src.total_revenue
OR tgt.total_boarded IS DISTINCT FROM src.total_boarded
OR tgt.unique_passengers IS DISTINCT FROM src.unique_passengers
OR tgt.airplane_model IS DISTINCT FROM src.airplane_model
);
-- Шаг 5: INSERT новых записей.
INSERT INTO dm.monthly_overview (
year_actual,
month_actual,
airplane_sk,
airplane_bk,
airplane_model,
total_seats,
total_flights,
total_tickets,
total_boarded,
total_revenue,
avg_ticket_price,
avg_load_factor,
unique_routes,
unique_passengers,
_load_id
)
SELECT
src.year_actual,
src.month_actual,
src.airplane_sk,
src.airplane_bk,
src.airplane_model,
src.total_seats,
src.total_flights,
src.total_tickets,
src.total_boarded,
src.total_revenue,
src.avg_ticket_price,
src.avg_load_factor,
src.unique_routes,
src.unique_passengers,
'{{ run_id }}' AS _load_id
FROM tmp_monthly_overview_delta AS src
WHERE NOT EXISTS (
SELECT 1 FROM dm.monthly_overview AS tgt
WHERE tgt.year_actual = src.year_actual
AND tgt.month_actual = src.month_actual
AND tgt.airplane_sk = src.airplane_sk
);
+13 -12
View File
@@ -29,7 +29,7 @@ JOIN dds.dim_routes r ON f.route_sk = r.route_sk
JOIN dds.dim_calendar cal ON f.calendar_sk = cal.calendar_sk
GROUP BY r.route_bk;
-- Шаг 3: Объединяем агрегаты с АКТУАЛЬНЫМИ атрибутами маршрута.
-- Шаг 3: Объединяем агрегаты с АКТУАЛЬНЫМИ атрибутами маршрута, аэропортов и самолетов.
INSERT INTO dm.route_performance (
route_bk,
route_sk,
@@ -54,13 +54,13 @@ INSERT INTO dm.route_performance (
SELECT
m.route_bk,
r_curr.route_sk,
r_curr.departure_airport_bk,
r_curr.departure_city,
r_curr.arrival_airport_bk,
r_curr.arrival_city,
r_curr.airplane_bk,
r_curr.airplane_model,
r_curr.total_seats,
dep.airport_bk AS departure_airport_bk,
dep.city AS departure_city,
arr.airport_bk AS arrival_airport_bk,
arr.city AS arrival_city,
air.airplane_bk,
air.model AS airplane_model,
air.total_seats,
m.total_flights,
m.total_tickets,
m.total_boarded,
@@ -69,14 +69,15 @@ SELECT
(m.total_revenue / NULLIF(m.total_tickets, 0))::NUMERIC(10,2),
m.avg_boarding_rate::NUMERIC(5,4),
-- Load Factor: общее кол-во посадок / (кол-во рейсов * мест в самолете)
-- Это учебное допущение (упрощение), т.к. самолет мог меняться.
ROUND(
m.total_boarded::NUMERIC / NULLIF(m.total_flights * r_curr.total_seats, 0),
m.total_boarded::NUMERIC / NULLIF(m.total_flights * air.total_seats, 0),
4
) AS avg_load_factor,
m.first_flight_date,
m.last_flight_date,
'{{ run_id }}' AS _load_id
FROM tmp_route_metrics m
JOIN dds.dim_routes r_curr ON m.route_bk = r_curr.route_bk
WHERE r_curr.valid_to IS NULL; -- Берем только АКТУАЛЬНУЮ версию для денормализации
JOIN dds.dim_routes r_curr ON m.route_bk = r_curr.route_bk AND r_curr.valid_to IS NULL
JOIN dds.dim_airports dep ON r_curr.departure_airport = dep.airport_bk
JOIN dds.dim_airports arr ON r_curr.arrival_airport = arr.airport_bk
JOIN dds.dim_airplanes air ON r_curr.airplane_code = air.airplane_bk;