feat(main): заглушки студенческих файлов, ослабление DQ, адаптация тестов

- fact_flight_sales_dq: student SK (passenger/route/airplane) → NOTICE,
  airport_sk → порог 1% (эталон через ods.routes)
- routes_dq: закомментирован RI airplane_code → ods.airplanes
- ODS DAG: убрана зависимость dq_ods_airplanes → load_ods_routes
- 18 студенческих файлов заменены заглушками (ODS/DDS/DM load+dq)
- test_ods_sql_contract: SNAPSHOT_ENTITIES без airplanes/seats
- test_dags_smoke: убран assert dq_ods_airplanes → dq_ods_routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 23:07:14 +03:00
co-authored by Claude Opus 4.6
parent a8148c8afa
commit 5ca4e6b2ca
23 changed files with 120 additions and 1703 deletions
+3 -49
View File
@@ -1,49 +1,3 @@
-- DQ проверки для витрины dm.airport_traffic.
--
-- Учебные цели:
-- 1. Проверка арифметических инвариантов (сумма частей должна быть равна целому).
-- 2. Валидация уникальности составного ключа (зерна).
DO $$
DECLARE
row_count INTEGER;
duplicate_count INTEGER;
invalid_total_count INTEGER;
negative_metrics_count INTEGER;
BEGIN
-- 1. Проверка на наполненность
SELECT COUNT(*) INTO row_count FROM dm.airport_traffic;
IF row_count = 0 THEN
RAISE EXCEPTION 'DQ Error: Таблица dm.airport_traffic пуста.';
END IF;
-- 2. Проверка на уникальность (зерно - traffic_date + airport_sk)
SELECT COUNT(*) INTO duplicate_count
FROM (
SELECT traffic_date, airport_sk FROM dm.airport_traffic
GROUP BY traffic_date, airport_sk HAVING COUNT(*) > 1
) q;
IF duplicate_count > 0 THEN
RAISE EXCEPTION 'DQ Error: В dm.airport_traffic обнаружены дубликаты по (date, airport_sk) (% шт).', duplicate_count;
END IF;
-- 3. Проверка инварианта total = departures + arrivals
SELECT COUNT(*) INTO invalid_total_count
FROM dm.airport_traffic
WHERE total_passengers != (departures_passengers + arrivals_passengers);
IF invalid_total_count > 0 THEN
RAISE EXCEPTION 'DQ Error: В dm.airport_traffic нарушен инвариант total_passengers = dep + arr (% строк).', invalid_total_count;
END IF;
-- 4. Проверка на отрицательные значения
SELECT COUNT(*) INTO negative_metrics_count
FROM dm.airport_traffic
WHERE departures_flights < 0 OR arrivals_flights < 0 OR departures_revenue < 0 OR arrivals_revenue < 0;
IF negative_metrics_count > 0 THEN
RAISE EXCEPTION 'DQ Error: В dm.airport_traffic обнаружены отрицательные метрики (% строк).', negative_metrics_count;
END IF;
RAISE NOTICE 'DQ Success: dm.airport_traffic успешно прошла все проверки (% строк).', row_count;
END $$;
-- TODO: реализуйте проверки качества данных для dm.airport_traffic
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3 -131
View File
@@ -1,131 +1,3 @@
-- Загрузка витрины dm.airport_traffic: инкрементальный UPSERT по датам.
--
-- Учебные цели:
-- 1. Обработка dual-role dimensions через UNION ALL:
-- Мы превращаем один билет (факт) в два "события": вылет и прилет.
-- Это позволяет собрать единую статистику аэропорта в одном проходе.
-- 2. Инкремент по датам (HWM): в этой витрине метрики ограничены сутками,
-- поэтому пересчитываем только те дни, где появились новые факты.
-- 3. Агрегация рейсов: используем COUNT(DISTINCT flight_id) для подсчета рейсов.
-- 4. Денормализация (SCD1): обновление атрибутов (город) при изменениях в измерении.
-- Шаг 1: Находим даты, затронутые новыми/измененными фактами.
CREATE TEMP TABLE tmp_traffic_affected_dates ON COMMIT DROP AS
SELECT DISTINCT cal.date_actual AS traffic_date
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.airport_traffic
);
-- Шаг 2: Unpivot (UNION ALL) и агрегация по затронутым датам.
CREATE TEMP TABLE tmp_airport_traffic_delta ON COMMIT DROP AS
WITH raw_events AS (
-- Роль 1: Аэропорт вылета
SELECT
cal.date_actual AS traffic_date,
f.departure_airport_sk AS airport_sk,
f.flight_id,
'departure' AS role,
(CASE WHEN f.is_boarded THEN 1 ELSE 0 END) AS is_passenger,
f.price AS revenue
FROM dds.fact_flight_sales f
JOIN dds.dim_calendar cal ON f.calendar_sk = cal.calendar_sk
WHERE cal.date_actual IN (SELECT traffic_date FROM tmp_traffic_affected_dates)
UNION ALL
-- Роль 2: Аэропорт прилета
SELECT
cal.date_actual AS traffic_date,
f.arrival_airport_sk AS airport_sk,
f.flight_id,
'arrival' AS role,
(CASE WHEN f.is_boarded THEN 1 ELSE 0 END) AS is_passenger,
f.price AS revenue
FROM dds.fact_flight_sales f
JOIN dds.dim_calendar cal ON f.calendar_sk = cal.calendar_sk
WHERE cal.date_actual IN (SELECT traffic_date FROM tmp_traffic_affected_dates)
)
SELECT
e.traffic_date,
e.airport_sk,
a.airport_bk, -- Берем из dim_airports.airport_bk
a.city,
-- Агрегация вылетов
COUNT(DISTINCT CASE WHEN e.role = 'departure' THEN e.flight_id END) AS departures_flights,
SUM(CASE WHEN e.role = 'departure' THEN e.is_passenger ELSE 0 END) AS departures_passengers,
SUM(CASE WHEN e.role = 'departure' THEN e.revenue ELSE 0 END) AS departures_revenue,
-- Агрегация прилетов
COUNT(DISTINCT CASE WHEN e.role = 'arrival' THEN e.flight_id END) AS arrivals_flights,
SUM(CASE WHEN e.role = 'arrival' THEN e.is_passenger ELSE 0 END) AS arrivals_passengers,
SUM(CASE WHEN e.role = 'arrival' THEN e.revenue ELSE 0 END) AS arrivals_revenue,
-- Итого: все пассажиры (сели + вышли в этом аэропорту)
SUM(e.is_passenger) AS total_passengers
FROM raw_events e
JOIN dds.dim_airports a ON e.airport_sk = a.airport_sk
GROUP BY e.traffic_date, e.airport_sk, a.airport_bk, a.city;
-- Шаг 3: UPDATE существующих записей (включая денормализованные атрибуты).
UPDATE dm.airport_traffic AS tgt
SET
airport_bk = src.airport_bk,
city = src.city,
departures_flights = src.departures_flights,
departures_passengers = src.departures_passengers,
departures_revenue = src.departures_revenue,
arrivals_flights = src.arrivals_flights,
arrivals_passengers = src.arrivals_passengers,
arrivals_revenue = src.arrivals_revenue,
total_passengers = src.total_passengers,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM tmp_airport_traffic_delta AS src
WHERE tgt.traffic_date = src.traffic_date
AND tgt.airport_sk = src.airport_sk
AND (
tgt.total_passengers IS DISTINCT FROM src.total_passengers
OR tgt.departures_flights IS DISTINCT FROM src.departures_flights
OR tgt.arrivals_flights IS DISTINCT FROM src.arrivals_flights
OR tgt.city IS DISTINCT FROM src.city
OR tgt.airport_bk IS DISTINCT FROM src.airport_bk
);
-- Шаг 4: INSERT новых записей.
INSERT INTO dm.airport_traffic (
traffic_date,
airport_sk,
airport_bk,
city,
departures_flights,
departures_passengers,
departures_revenue,
arrivals_flights,
arrivals_passengers,
arrivals_revenue,
total_passengers,
_load_id
)
SELECT
src.traffic_date,
src.airport_sk,
src.airport_bk,
src.city,
src.departures_flights,
src.departures_passengers,
src.departures_revenue,
src.arrivals_flights,
src.arrivals_passengers,
src.arrivals_revenue,
src.total_passengers,
'{{ run_id }}' AS _load_id
FROM tmp_airport_traffic_delta AS src
WHERE NOT EXISTS (
SELECT 1 FROM dm.airport_traffic AS tgt
WHERE tgt.traffic_date = src.traffic_date
AND tgt.airport_sk = src.airport_sk
);
ANALYZE dm.airport_traffic;
-- TODO: реализуйте загрузку витрины dm.airport_traffic (см. ТЗ в docs/assignment/analyst_spec.md)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3 -52
View File
@@ -1,52 +1,3 @@
-- 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 $$;
-- TODO: реализуйте проверки качества данных для dm.monthly_overview
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3 -167
View File
@@ -1,167 +1,3 @@
-- Загрузка витрины 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
);
ANALYZE dm.monthly_overview;
-- TODO: реализуйте загрузку витрины dm.monthly_overview (см. ТЗ в docs/assignment/analyst_spec.md)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3 -51
View File
@@ -1,51 +1,3 @@
-- DQ проверки для витрины dm.passenger_loyalty.
--
-- Учебные цели:
-- 1. Валидация бизнес-логики дат (стаж не может быть отрицательным).
-- 2. Учебная проверка ссылочной целостности (FK Integrity).
DO $$
DECLARE
row_count INTEGER;
duplicate_count INTEGER;
invalid_dates_count INTEGER;
orphan_keys_count INTEGER;
BEGIN
-- 1. Проверка на наполненность
SELECT COUNT(*) INTO row_count FROM dm.passenger_loyalty;
IF row_count = 0 THEN
RAISE EXCEPTION 'DQ Error: Таблица dm.passenger_loyalty пуста.';
END IF;
-- 2. Проверка на уникальность (зерно - passenger_sk)
SELECT COUNT(*) INTO duplicate_count
FROM (
SELECT passenger_sk FROM dm.passenger_loyalty
GROUP BY passenger_sk HAVING COUNT(*) > 1
) q;
IF duplicate_count > 0 THEN
RAISE EXCEPTION 'DQ Error: В dm.passenger_loyalty обнаружены дубликаты по passenger_sk (% шт).', duplicate_count;
END IF;
-- 3. Проверка логики дат
SELECT COUNT(*) INTO invalid_dates_count
FROM dm.passenger_loyalty
WHERE first_flight_date > last_flight_date;
IF invalid_dates_count > 0 THEN
RAISE EXCEPTION 'DQ Error: В dm.passenger_loyalty обнаружены записи с first_date > last_date (% строк).', invalid_dates_count;
END IF;
-- 4. Учебная проверка ссылочной целостности (FK Check)
-- В продакшене это обычно гарантируется JOIN при загрузке, но здесь мы показываем саму возможность проверки.
SELECT COUNT(*) INTO orphan_keys_count
FROM dm.passenger_loyalty tgt
LEFT JOIN dds.dim_passengers p ON tgt.passenger_sk = p.passenger_sk
WHERE p.passenger_sk IS NULL;
IF orphan_keys_count > 0 THEN
RAISE EXCEPTION 'DQ Error: В dm.passenger_loyalty обнаружены пассажиры, отсутствующие в dim_passengers (% строк).', orphan_keys_count;
END IF;
RAISE NOTICE 'DQ Success: dm.passenger_loyalty успешно прошла все проверки (% строк).', row_count;
END $$;
-- TODO: реализуйте проверки качества данных для dm.passenger_loyalty
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3 -131
View File
@@ -1,131 +1,3 @@
-- Загрузка витрины dm.passenger_loyalty: инкрементальный UPSERT по затронутым ключам.
--
-- Учебные цели:
-- 1. Метод "затронутых ключей": HWM по _load_ts находит затронутые ID пассажиров,
-- а затем мы ПЕРЕСЧИТЫВАЕМ всю историю именно для этого круга лиц.
-- Это гарантирует точность накопительных агрегатов (total_spent, dates).
-- 2. Использование DISTINCT ON (PostgreSQL-специфика): самый лаконичный способ
-- найти "самое частое" (моду) в рамках группы.
-- 3. Обработка NULL в фактах: фильтрация (passenger_sk IS NOT NULL).
-- 4. Агрегация SCD2-измерений: при подсчете уникальных маршрутов (dim_routes)
-- нужно агрегировать по BK (route_bk), т.к. один маршрут может иметь несколько SK (версий).
-- Шаг 1: Находим ID пассажиров, чьи данные изменились или добавились в фактах.
CREATE TEMP TABLE tmp_loyalty_affected_keys ON COMMIT DROP AS
SELECT DISTINCT passenger_sk
FROM dds.fact_flight_sales
WHERE _load_ts > (
SELECT COALESCE(MAX(_load_ts), '1900-01-01'::TIMESTAMP)
FROM dm.passenger_loyalty
)
AND passenger_sk IS NOT NULL;
-- Шаг 2: Для затронутых лиц считаем ИТОГОВЫЕ агрегаты по ВСЕЙ истории фактов.
CREATE TEMP TABLE tmp_passenger_delta ON COMMIT DROP AS
WITH base_metrics AS (
-- Агрегируем количественные метрики.
-- JOIN dds.dim_routes нужен для подсчета УНИКАЛЬНЫХ маршрутов по BK (т.к. dim_routes - SCD2).
SELECT
f.passenger_sk,
COUNT(DISTINCT f.book_ref) AS total_bookings,
COUNT(*) AS total_flights,
SUM(CASE WHEN f.is_boarded THEN 1 ELSE 0 END) AS total_boarded,
SUM(f.price) AS total_spent,
COUNT(DISTINCT r.route_bk) AS unique_routes, -- Агрегация по BK (бизнес-ключу) маршрута
MIN(cal.date_actual) AS first_flight_date,
MAX(cal.date_actual) AS last_flight_date
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 f.passenger_sk IN (SELECT passenger_sk FROM tmp_loyalty_affected_keys)
GROUP BY f.passenger_sk
),
fare_modes AS (
-- Находим самый частый класс обслуживания для каждого пассажира.
-- DISTINCT ON при ничьей выбирает произвольный вариант из топ-результатов.
SELECT DISTINCT ON (f.passenger_sk)
f.passenger_sk,
tar.fare_conditions AS favorite_fare_conditions
FROM dds.fact_flight_sales f
JOIN dds.dim_tariffs tar ON f.tariff_sk = tar.tariff_sk
WHERE f.passenger_sk IN (SELECT passenger_sk FROM tmp_loyalty_affected_keys)
GROUP BY f.passenger_sk, tar.fare_conditions
ORDER BY f.passenger_sk, COUNT(*) DESC
)
SELECT
bm.*,
fm.favorite_fare_conditions,
p.passenger_id AS passenger_bk,
p.passenger_name,
(bm.last_flight_date - bm.first_flight_date) AS days_as_customer
FROM base_metrics bm
JOIN dds.dim_passengers p ON bm.passenger_sk = p.passenger_sk
JOIN fare_modes fm ON bm.passenger_sk = fm.passenger_sk;
-- Шаг 3: UPDATE существующих записей.
UPDATE dm.passenger_loyalty AS tgt
SET
passenger_name = src.passenger_name,
total_bookings = src.total_bookings,
total_flights = src.total_flights,
total_boarded = src.total_boarded,
total_spent = src.total_spent,
avg_ticket_price = (src.total_spent / NULLIF(src.total_flights, 0))::NUMERIC(10,2),
favorite_fare_conditions = src.favorite_fare_conditions,
unique_routes = src.unique_routes,
first_flight_date = src.first_flight_date,
last_flight_date = src.last_flight_date,
days_as_customer = src.days_as_customer,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM tmp_passenger_delta AS src
WHERE tgt.passenger_sk = src.passenger_sk
AND (
-- Обновляем только если что-то реально изменилось
tgt.total_flights IS DISTINCT FROM src.total_flights
OR tgt.total_boarded IS DISTINCT FROM src.total_boarded
OR tgt.total_spent IS DISTINCT FROM src.total_spent
OR tgt.last_flight_date IS DISTINCT FROM src.last_flight_date
OR tgt.passenger_name IS DISTINCT FROM src.passenger_name
);
-- Шаг 4: INSERT новых пассажиров.
INSERT INTO dm.passenger_loyalty (
passenger_sk,
passenger_bk,
passenger_name,
total_bookings,
total_flights,
total_boarded,
total_spent,
avg_ticket_price,
favorite_fare_conditions,
unique_routes,
first_flight_date,
last_flight_date,
days_as_customer,
_load_id
)
SELECT
src.passenger_sk,
src.passenger_bk,
src.passenger_name,
src.total_bookings,
src.total_flights,
src.total_boarded,
src.total_spent,
(src.total_spent / NULLIF(src.total_flights, 0))::NUMERIC(10,2),
src.favorite_fare_conditions,
src.unique_routes,
src.first_flight_date,
src.last_flight_date,
src.days_as_customer,
'{{ run_id }}' AS _load_id
FROM tmp_passenger_delta AS src
WHERE NOT EXISTS (
SELECT 1 FROM dm.passenger_loyalty AS tgt
WHERE tgt.passenger_sk = src.passenger_sk
);
ANALYZE dm.passenger_loyalty;
-- TODO: реализуйте загрузку витрины dm.passenger_loyalty (см. ТЗ в docs/assignment/analyst_spec.md)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3 -59
View File
@@ -1,59 +1,3 @@
-- DQ проверки для витрины dm.route_performance.
--
-- Учебные цели:
-- 1. Использование PL/pgSQL блоков (DO $$ ...) для кастомных проверок.
-- 2. Валидация бизнес-инвариантов после загрузки Full Rebuild.
DO $$
DECLARE
row_count INTEGER;
duplicate_count INTEGER;
invalid_metrics_count INTEGER;
null_attributes_count INTEGER;
BEGIN
-- 1. Проверка на наполненность
SELECT COUNT(*) INTO row_count FROM dm.route_performance;
IF row_count = 0 THEN
RAISE EXCEPTION 'DQ Error: Таблица dm.route_performance пуста после загрузки.';
END IF;
-- 2. Проверка на уникальность бизнес-ключа
SELECT COUNT(*) INTO duplicate_count
FROM (
SELECT route_bk FROM dm.route_performance
GROUP BY route_bk HAVING COUNT(*) > 1
) AS q;
IF duplicate_count > 0 THEN
RAISE EXCEPTION 'DQ Error: В dm.route_performance обнаружены дубликаты по route_bk (% шт).', duplicate_count;
END IF;
-- 3. Проверка бизнес-метрик (инварианты)
-- Load factor не может быть отрицательным или физически невозможным (например, более 200%)
SELECT COUNT(*) INTO invalid_metrics_count
FROM dm.route_performance
WHERE avg_load_factor < 0 OR avg_load_factor > 2.0;
IF invalid_metrics_count > 0 THEN
RAISE EXCEPTION 'DQ Error: В dm.route_performance обнаружены некорректные показатели load factor (% строк).', invalid_metrics_count;
END IF;
-- 4. Проверка обязательных атрибутов на NULL
SELECT COUNT(*) INTO null_attributes_count
FROM dm.route_performance
WHERE departure_city IS NULL OR arrival_city IS NULL OR airplane_model IS NULL;
IF null_attributes_count > 0 THEN
RAISE EXCEPTION 'DQ Error: В dm.route_performance обнаружены NULL-значения в обязательных полях (% строк).', null_attributes_count;
END IF;
-- 5. Проверка: посаженных не может быть больше, чем купивших билет
SELECT COUNT(*) INTO invalid_metrics_count
FROM dm.route_performance
WHERE total_boarded > total_tickets;
IF invalid_metrics_count > 0 THEN
RAISE EXCEPTION 'DQ Error: В dm.route_performance total_boarded > total_tickets (% строк).', invalid_metrics_count;
END IF;
RAISE NOTICE 'DQ Success: dm.route_performance успешно прошла все проверки (% строк).', row_count;
END $$;
-- TODO: реализуйте проверки качества данных для dm.route_performance
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;
+3 -83
View File
@@ -1,83 +1,3 @@
-- Загрузка витрины dm.route_performance: Full Rebuild.
--
-- Учебные цели:
-- 1. Демонстрация TRUNCATE + INSERT. Таблица маленькая (~1000 строк),
-- пересоздать её с нуля дешевле, чем вычислять дельту.
-- 2. Обработка SCD2: агрегируем факты по бизнес-ключу route_bk,
-- чтобы собрать данные со всех исторических версий маршрута.
-- 3. Денормализация атрибутов из ТЕКУЩЕЙ (актуальной) версии измерения.
-- Шаг 1: Очистка таблицы (AO Column Store не поддерживает эффективный DELETE/UPDATE).
TRUNCATE dm.route_performance;
-- Шаг 2: Агрегация всех фактов по бизнес-ключу маршрута.
-- Используем временную таблицу для удобства и читаемости.
CREATE TEMP TABLE tmp_route_metrics ON COMMIT DROP AS
SELECT
r.route_bk,
COUNT(DISTINCT f.flight_id) AS total_flights,
COUNT(*) AS total_tickets,
SUM(CASE WHEN f.is_boarded THEN 1 ELSE 0 END) AS total_boarded,
SUM(f.price) AS total_revenue,
MIN(cal.date_actual) AS first_flight_date,
MAX(cal.date_actual) AS last_flight_date,
-- Доля посаженных пассажиров среди всех проданных билетов маршрута
-- (упрощение: не взвешено по рейсам)
AVG(CASE WHEN f.is_boarded THEN 1 ELSE 0 END::NUMERIC) AS avg_boarding_rate
FROM dds.fact_flight_sales f
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: Объединяем агрегаты с АКТУАЛЬНЫМИ атрибутами маршрута.
-- Учебный комментарий: Благодаря денормализации dim_routes — один JOIN вместо четырёх.
INSERT INTO dm.route_performance (
route_bk,
route_sk,
departure_airport_bk,
departure_city,
arrival_airport_bk,
arrival_city,
airplane_bk,
airplane_model,
total_seats,
total_flights,
total_tickets,
total_boarded,
total_revenue,
avg_ticket_price,
avg_boarding_rate,
avg_load_factor,
first_flight_date,
last_flight_date,
_load_id
)
SELECT
m.route_bk,
r_curr.route_sk,
r_curr.departure_airport AS departure_airport_bk,
r_curr.departure_city,
r_curr.arrival_airport AS arrival_airport_bk,
r_curr.arrival_city,
r_curr.airplane_code AS airplane_bk,
r_curr.airplane_model,
r_curr.total_seats,
m.total_flights,
m.total_tickets,
m.total_boarded,
m.total_revenue,
-- Средний чек
(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),
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 AND r_curr.valid_to IS NULL;
ANALYZE dm.route_performance;
-- TODO: реализуйте загрузку витрины dm.route_performance (см. ТЗ в docs/assignment/analyst_spec.md)
-- Эталонную реализацию можно найти в ветке solution.
SELECT 1;