feat(dds): реализован слой dds для bookings

- Зачем:
  - подготовлен учебный Star Schema слой для перехода от ODS к аналитике и витринам.
- Что:
  - добавлены 21 SQL-файл для DDS (DDL/LOAD/DQ) с SCD1/SCD2 и фактом `fact_flight_sales`.
  - добавлены DAG `bookings_dds_ddl` и `bookings_to_gp_dds`, а также smoke-тесты структуры DAG.
  - обновлены `sql/ddl_gp.sql` и документация (`README`, `docs/*`, `db_schema`) под поток `stg -> ods -> dds`.
- Проверка:
  - make test.
This commit is contained in:
2026-02-28 22:13:11 +03:00
parent fc798aadd1
commit e592b35461
30 changed files with 1856 additions and 15 deletions
+17
View File
@@ -0,0 +1,17 @@
-- DDL для DDS-слоя по таблице dim_airplanes (SCD1-измерение).
CREATE SCHEMA IF NOT EXISTS dds;
CREATE TABLE IF NOT EXISTS dds.dim_airplanes (
airplane_sk INTEGER NOT NULL,
airplane_bk TEXT NOT NULL,
model TEXT NOT NULL,
range_km INTEGER,
speed_kmh INTEGER,
total_seats INTEGER,
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()
)
DISTRIBUTED BY (airplane_sk);
+83
View File
@@ -0,0 +1,83 @@
-- DQ для DDS dim_airplanes.
DO $$
DECLARE
v_row_count BIGINT;
v_dup_sk BIGINT;
v_dup_bk BIGINT;
v_missing_bk BIGINT;
v_null_count BIGINT;
BEGIN
-- Таблица не пуста.
SELECT COUNT(*)
INTO v_row_count
FROM dds.dim_airplanes;
IF v_row_count = 0 THEN
RAISE EXCEPTION 'DQ FAILED: dds.dim_airplanes пуста.';
END IF;
-- Нет дублей по SK.
SELECT COUNT(*) - COUNT(DISTINCT airplane_sk)
INTO v_dup_sk
FROM dds.dim_airplanes;
IF v_dup_sk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_airplanes найдены дубликаты airplane_sk: %',
v_dup_sk;
END IF;
-- Нет дублей по BK.
SELECT COUNT(*) - COUNT(DISTINCT airplane_bk)
INTO v_dup_bk
FROM dds.dim_airplanes;
IF v_dup_bk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_airplanes найдены дубликаты airplane_bk: %',
v_dup_bk;
END IF;
-- Покрытие ODS: все airplane_code из ODS есть в DDS.
SELECT COUNT(*)
INTO v_missing_bk
FROM (SELECT DISTINCT airplane_code FROM ods.airplanes) AS s
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_airplanes AS d
WHERE d.airplane_bk = s.airplane_code
);
IF v_missing_bk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_airplanes отсутствуют ключи из ods.airplanes: %',
v_missing_bk;
END IF;
-- Обязательные поля.
SELECT COUNT(*)
INTO v_null_count
FROM dds.dim_airplanes
WHERE airplane_sk IS NULL
OR airplane_bk IS NULL
OR airplane_bk = ''
OR model IS NULL
OR model = ''
OR total_seats IS NULL
OR created_at IS NULL
OR updated_at IS NULL
OR _load_id IS NULL
OR _load_id = ''
OR _load_ts IS NULL;
IF v_null_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_airplanes найдены NULL/пустые обязательные поля: %',
v_null_count;
END IF;
RAISE NOTICE
'DQ PASSED: dds.dim_airplanes ок, строк=%',
v_row_count;
END $$;
+92
View File
@@ -0,0 +1,92 @@
-- Загрузка DDS dim_airplanes: SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
-- Statement 1: UPDATE существующих записей (если атрибуты изменились).
WITH seats_agg AS (
SELECT
s.airplane_code,
COUNT(*)::INTEGER AS total_seats
FROM ods.seats AS s
GROUP BY s.airplane_code
),
src AS (
SELECT
a.airplane_code,
a.model,
a.range_km,
a.speed_kmh,
COALESCE(sa.total_seats, 0) AS total_seats
FROM ods.airplanes AS a
LEFT JOIN seats_agg AS sa
ON sa.airplane_code = a.airplane_code
)
UPDATE dds.dim_airplanes AS d
SET model = s.model,
range_km = s.range_km,
speed_kmh = s.speed_kmh,
total_seats = s.total_seats,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM src AS s
WHERE d.airplane_bk = s.airplane_code
AND (
d.model IS DISTINCT FROM s.model
OR d.range_km IS DISTINCT FROM s.range_km
OR d.speed_kmh IS DISTINCT FROM s.speed_kmh
OR d.total_seats IS DISTINCT FROM s.total_seats
);
-- Statement 2: INSERT новых записей (MAX(sk) + ROW_NUMBER()).
WITH seats_agg AS (
SELECT
s.airplane_code,
COUNT(*)::INTEGER AS total_seats
FROM ods.seats AS s
GROUP BY s.airplane_code
),
src AS (
SELECT
a.airplane_code,
a.model,
a.range_km,
a.speed_kmh,
COALESCE(sa.total_seats, 0) AS total_seats
FROM ods.airplanes AS a
LEFT JOIN seats_agg AS sa
ON sa.airplane_code = a.airplane_code
),
max_sk AS (
SELECT COALESCE(MAX(airplane_sk), 0) AS v
FROM dds.dim_airplanes
)
INSERT INTO dds.dim_airplanes (
airplane_sk,
airplane_bk,
model,
range_km,
speed_kmh,
total_seats,
created_at,
updated_at,
_load_id,
_load_ts
)
SELECT
(SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.airplane_code)::INTEGER,
s.airplane_code,
s.model,
s.range_km,
s.speed_kmh,
s.total_seats,
now(),
now(),
'{{ run_id }}',
now()
FROM src AS s
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_airplanes AS d
WHERE d.airplane_bk = s.airplane_code
);
ANALYZE dds.dim_airplanes;
+18
View File
@@ -0,0 +1,18 @@
-- DDL для DDS-слоя по таблице dim_airports (SCD1-измерение).
CREATE SCHEMA IF NOT EXISTS dds;
CREATE TABLE IF NOT EXISTS dds.dim_airports (
airport_sk INTEGER NOT NULL,
airport_bk TEXT NOT NULL,
airport_name TEXT NOT NULL,
city TEXT NOT NULL,
country TEXT NOT NULL,
timezone TEXT NOT NULL,
coordinates TEXT,
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()
)
DISTRIBUTED BY (airport_sk);
+88
View File
@@ -0,0 +1,88 @@
-- DQ для DDS dim_airports.
DO $$
DECLARE
v_row_count BIGINT;
v_dup_sk BIGINT;
v_dup_bk BIGINT;
v_missing_bk BIGINT;
v_null_count BIGINT;
BEGIN
-- Таблица не пуста.
SELECT COUNT(*)
INTO v_row_count
FROM dds.dim_airports;
IF v_row_count = 0 THEN
RAISE EXCEPTION 'DQ FAILED: dds.dim_airports пуста.';
END IF;
-- Нет дублей по SK.
SELECT COUNT(*) - COUNT(DISTINCT airport_sk)
INTO v_dup_sk
FROM dds.dim_airports;
IF v_dup_sk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_airports найдены дубликаты airport_sk: %',
v_dup_sk;
END IF;
-- Нет дублей по BK.
SELECT COUNT(*) - COUNT(DISTINCT airport_bk)
INTO v_dup_bk
FROM dds.dim_airports;
IF v_dup_bk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_airports найдены дубликаты airport_bk: %',
v_dup_bk;
END IF;
-- Покрытие ODS: все airport_code из ODS есть в DDS.
SELECT COUNT(*)
INTO v_missing_bk
FROM (SELECT DISTINCT airport_code FROM ods.airports) AS s
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_airports AS d
WHERE d.airport_bk = s.airport_code
);
IF v_missing_bk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_airports отсутствуют ключи из ods.airports: %',
v_missing_bk;
END IF;
-- Обязательные поля.
SELECT COUNT(*)
INTO v_null_count
FROM dds.dim_airports
WHERE airport_sk IS NULL
OR airport_bk IS NULL
OR airport_bk = ''
OR airport_name IS NULL
OR airport_name = ''
OR city IS NULL
OR city = ''
OR country IS NULL
OR country = ''
OR timezone IS NULL
OR timezone = ''
OR created_at IS NULL
OR updated_at IS NULL
OR _load_id IS NULL
OR _load_id = ''
OR _load_ts IS NULL;
IF v_null_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_airports найдены NULL/пустые обязательные поля: %',
v_null_count;
END IF;
RAISE NOTICE
'DQ PASSED: dds.dim_airports ок, строк=%',
v_row_count;
END $$;
+60
View File
@@ -0,0 +1,60 @@
-- Загрузка DDS dim_airports: SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
-- Statement 1: UPDATE существующих записей (если атрибуты изменились).
UPDATE dds.dim_airports AS d
SET airport_name = s.airport_name,
city = s.city,
country = s.country,
timezone = s.timezone,
coordinates = s.coordinates,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM ods.airports AS s
WHERE d.airport_bk = s.airport_code
AND (
d.airport_name IS DISTINCT FROM s.airport_name
OR d.city IS DISTINCT FROM s.city
OR d.country IS DISTINCT FROM s.country
OR d.timezone IS DISTINCT FROM s.timezone
OR d.coordinates IS DISTINCT FROM s.coordinates
);
-- Statement 2: INSERT новых записей (MAX(sk) + ROW_NUMBER()).
WITH max_sk AS (
SELECT COALESCE(MAX(airport_sk), 0) AS v
FROM dds.dim_airports
)
INSERT INTO dds.dim_airports (
airport_sk,
airport_bk,
airport_name,
city,
country,
timezone,
coordinates,
created_at,
updated_at,
_load_id,
_load_ts
)
SELECT
(SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.airport_code)::INTEGER,
s.airport_code,
s.airport_name,
s.city,
s.country,
s.timezone,
s.coordinates,
now(),
now(),
'{{ run_id }}',
now()
FROM ods.airports AS s
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_airports AS d
WHERE d.airport_bk = s.airport_code
);
ANALYZE dds.dim_airports;
+15
View File
@@ -0,0 +1,15 @@
-- DDL для DDS-слоя по таблице dim_calendar (статическое измерение дат).
CREATE SCHEMA IF NOT EXISTS dds;
CREATE TABLE IF NOT EXISTS dds.dim_calendar (
calendar_sk INTEGER NOT NULL,
date_actual DATE NOT NULL,
year_actual INTEGER NOT NULL,
month_actual INTEGER NOT NULL,
day_actual INTEGER NOT NULL,
day_of_week INTEGER NOT NULL,
day_name TEXT NOT NULL,
is_weekend BOOLEAN NOT NULL
)
DISTRIBUTED BY (calendar_sk);
+87
View File
@@ -0,0 +1,87 @@
-- DQ для DDS dim_calendar.
DO $$
DECLARE
v_row_count BIGINT;
v_dup_sk BIGINT;
v_dup_date BIGINT;
v_null_count BIGINT;
v_missing_flight_dates BIGINT;
BEGIN
-- Таблица должна быть достаточно заполнена.
SELECT COUNT(*)
INTO v_row_count
FROM dds.dim_calendar;
IF v_row_count < 1000 THEN
RAISE EXCEPTION
'DQ FAILED: dds.dim_calendar содержит слишком мало строк: %',
v_row_count;
END IF;
-- Нет дублей по surrogate key.
SELECT COUNT(*) - COUNT(DISTINCT calendar_sk)
INTO v_dup_sk
FROM dds.dim_calendar;
IF v_dup_sk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_calendar найдены дубликаты calendar_sk: %',
v_dup_sk;
END IF;
-- Нет дублей по business key (date_actual).
SELECT COUNT(*) - COUNT(DISTINCT date_actual)
INTO v_dup_date
FROM dds.dim_calendar;
IF v_dup_date <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_calendar найдены дубликаты date_actual: %',
v_dup_date;
END IF;
-- Обязательные поля не NULL.
SELECT COUNT(*)
INTO v_null_count
FROM dds.dim_calendar
WHERE calendar_sk IS NULL
OR date_actual IS NULL
OR year_actual IS NULL
OR month_actual IS NULL
OR day_actual IS NULL
OR day_of_week IS NULL
OR day_name IS NULL
OR day_name = ''
OR is_weekend IS NULL;
IF v_null_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_calendar найдены NULL/пустые обязательные поля: %',
v_null_count;
END IF;
-- Календарь покрывает даты вылета из ODS (где scheduled_departure не NULL).
SELECT COUNT(*)
INTO v_missing_flight_dates
FROM (
SELECT DISTINCT f.scheduled_departure::DATE AS departure_date
FROM ods.flights AS f
WHERE f.scheduled_departure IS NOT NULL
) AS src
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_calendar AS c
WHERE c.date_actual = src.departure_date
);
IF v_missing_flight_dates <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: dds.dim_calendar не покрывает даты вылета из ods.flights: %',
v_missing_flight_dates;
END IF;
RAISE NOTICE
'DQ PASSED: dds.dim_calendar ок, строк=%',
v_row_count;
END $$;
+29
View File
@@ -0,0 +1,29 @@
-- Загрузка DDS dim_calendar: статическое измерение (генерация дат).
-- Заполняем только если таблица пуста (идемпотентно).
INSERT INTO dds.dim_calendar (
calendar_sk,
date_actual,
year_actual,
month_actual,
day_actual,
day_of_week,
day_name,
is_weekend
)
SELECT
ROW_NUMBER() OVER (ORDER BY d.date_actual)::INTEGER AS calendar_sk,
d.date_actual,
EXTRACT(YEAR FROM d.date_actual)::INTEGER AS year_actual,
EXTRACT(MONTH FROM d.date_actual)::INTEGER AS month_actual,
EXTRACT(DAY FROM d.date_actual)::INTEGER AS day_actual,
EXTRACT(ISODOW FROM d.date_actual)::INTEGER AS day_of_week,
TO_CHAR(d.date_actual, 'FMDay') AS day_name,
EXTRACT(ISODOW FROM d.date_actual) IN (6, 7) AS is_weekend
FROM (
SELECT generate_series('2016-01-01'::DATE, '2030-12-31'::DATE, '1 day'::INTERVAL)::DATE
AS date_actual
) AS d
WHERE NOT EXISTS (SELECT 1 FROM dds.dim_calendar LIMIT 1);
ANALYZE dds.dim_calendar;
+14
View File
@@ -0,0 +1,14 @@
-- DDL для DDS-слоя по таблице dim_passengers (SCD1-измерение).
CREATE SCHEMA IF NOT EXISTS dds;
CREATE TABLE IF NOT EXISTS dds.dim_passengers (
passenger_sk INTEGER NOT NULL,
passenger_bk TEXT NOT NULL,
passenger_name TEXT NOT NULL,
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()
)
DISTRIBUTED BY (passenger_sk);
+100
View File
@@ -0,0 +1,100 @@
-- DQ для DDS dim_passengers.
DO $$
DECLARE
v_row_count BIGINT;
v_src_count BIGINT;
v_dup_sk BIGINT;
v_dup_bk BIGINT;
v_missing_bk BIGINT;
v_null_count BIGINT;
BEGIN
-- Для инкрементальных периодов без новых билетов допускаем пустую dim_passengers.
SELECT COUNT(*)
INTO v_row_count
FROM dds.dim_passengers;
SELECT COUNT(DISTINCT passenger_id)
INTO v_src_count
FROM ods.tickets
WHERE passenger_id IS NOT NULL
AND passenger_id <> '';
IF v_row_count = 0 AND v_src_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: dds.dim_passengers пуста при непустом источнике ods.tickets (passenger_id=%).',
v_src_count;
ELSIF v_row_count = 0 AND v_src_count = 0 THEN
RAISE NOTICE
'DQ PASSED: dds.dim_passengers пуста, т.к. в ods.tickets нет passenger_id для загрузки.';
RETURN;
END IF;
-- Нет дублей по SK.
SELECT COUNT(*) - COUNT(DISTINCT passenger_sk)
INTO v_dup_sk
FROM dds.dim_passengers;
IF v_dup_sk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_passengers найдены дубликаты passenger_sk: %',
v_dup_sk;
END IF;
-- Нет дублей по BK.
SELECT COUNT(*) - COUNT(DISTINCT passenger_bk)
INTO v_dup_bk
FROM dds.dim_passengers;
IF v_dup_bk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_passengers найдены дубликаты passenger_bk: %',
v_dup_bk;
END IF;
-- Покрытие ODS: все passenger_id из ods.tickets есть в DDS.
SELECT COUNT(*)
INTO v_missing_bk
FROM (
SELECT DISTINCT passenger_id
FROM ods.tickets
WHERE passenger_id IS NOT NULL
AND passenger_id <> ''
) AS s
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_passengers AS d
WHERE d.passenger_bk = s.passenger_id
);
IF v_missing_bk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_passengers отсутствуют passenger_id из ods.tickets: %',
v_missing_bk;
END IF;
-- Обязательные поля.
SELECT COUNT(*)
INTO v_null_count
FROM dds.dim_passengers
WHERE passenger_sk IS NULL
OR passenger_bk IS NULL
OR passenger_bk = ''
OR passenger_name IS NULL
OR passenger_name = ''
OR created_at IS NULL
OR updated_at IS NULL
OR _load_id IS NULL
OR _load_id = ''
OR _load_ts IS NULL;
IF v_null_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_passengers найдены NULL/пустые обязательные поля: %',
v_null_count;
END IF;
RAISE NOTICE
'DQ PASSED: dds.dim_passengers ок, строк=%',
v_row_count;
END $$;
+82
View File
@@ -0,0 +1,82 @@
-- Загрузка DDS dim_passengers: SCD1 UPSERT (UPDATE изменившихся + INSERT новых).
-- Statement 1: UPDATE существующих записей (если атрибуты изменились).
WITH src AS (
SELECT
d.passenger_id,
d.passenger_name
FROM (
SELECT
t.passenger_id,
t.passenger_name,
ROW_NUMBER() OVER (
PARTITION BY t.passenger_id
ORDER BY t.event_ts DESC NULLS LAST, t._load_ts DESC, t.ticket_no DESC
) AS rn
FROM ods.tickets AS t
WHERE t.passenger_id IS NOT NULL
AND t.passenger_id <> ''
AND t.passenger_name IS NOT NULL
AND t.passenger_name <> ''
) AS d
WHERE d.rn = 1
)
UPDATE dds.dim_passengers AS d
SET passenger_name = s.passenger_name,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM src AS s
WHERE d.passenger_bk = s.passenger_id
AND d.passenger_name IS DISTINCT FROM s.passenger_name;
-- Statement 2: INSERT новых записей (MAX(sk) + ROW_NUMBER()).
WITH src AS (
SELECT
d.passenger_id,
d.passenger_name
FROM (
SELECT
t.passenger_id,
t.passenger_name,
ROW_NUMBER() OVER (
PARTITION BY t.passenger_id
ORDER BY t.event_ts DESC NULLS LAST, t._load_ts DESC, t.ticket_no DESC
) AS rn
FROM ods.tickets AS t
WHERE t.passenger_id IS NOT NULL
AND t.passenger_id <> ''
AND t.passenger_name IS NOT NULL
AND t.passenger_name <> ''
) AS d
WHERE d.rn = 1
),
max_sk AS (
SELECT COALESCE(MAX(passenger_sk), 0) AS v
FROM dds.dim_passengers
)
INSERT INTO dds.dim_passengers (
passenger_sk,
passenger_bk,
passenger_name,
created_at,
updated_at,
_load_id,
_load_ts
)
SELECT
(SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.passenger_id)::INTEGER,
s.passenger_id,
s.passenger_name,
now(),
now(),
'{{ run_id }}',
now()
FROM src AS s
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_passengers AS d
WHERE d.passenger_bk = s.passenger_id
);
ANALYZE dds.dim_passengers;
+22
View File
@@ -0,0 +1,22 @@
-- DDL для DDS-слоя по таблице dim_routes (SCD2-измерение).
CREATE SCHEMA IF NOT EXISTS dds;
CREATE TABLE IF NOT EXISTS dds.dim_routes (
route_sk INTEGER NOT NULL,
route_bk TEXT NOT NULL,
departure_airport TEXT NOT NULL,
arrival_airport TEXT NOT NULL,
airplane_code TEXT NOT NULL,
days_of_week TEXT,
departure_time TIME,
duration INTERVAL,
hashdiff TEXT NOT NULL,
valid_from DATE NOT NULL,
valid_to DATE,
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()
)
DISTRIBUTED BY (route_sk);
+149
View File
@@ -0,0 +1,149 @@
-- DQ для DDS dim_routes (SCD2).
DO $$
DECLARE
v_row_count BIGINT;
v_dup_sk BIGINT;
v_dup_current BIGINT;
v_overlap_count BIGINT;
v_missing_count BIGINT;
v_orphan_current BIGINT;
v_null_count BIGINT;
BEGIN
-- Таблица не пуста.
SELECT COUNT(*)
INTO v_row_count
FROM dds.dim_routes;
IF v_row_count = 0 THEN
RAISE EXCEPTION 'DQ FAILED: dds.dim_routes пуста.';
END IF;
-- Нет дублей по SK.
SELECT COUNT(*) - COUNT(DISTINCT route_sk)
INTO v_dup_sk
FROM dds.dim_routes;
IF v_dup_sk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes найдены дубликаты route_sk: %',
v_dup_sk;
END IF;
-- Корректность интервалов (valid_from <= valid_to для закрытых версий).
SELECT COUNT(*)
INTO v_null_count
FROM dds.dim_routes
WHERE valid_to IS NOT NULL
AND valid_from > valid_to;
IF v_null_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes найдены версии с valid_from > valid_to: %',
v_null_count;
END IF;
-- Нет перекрытий интервалов для одного route_bk.
SELECT COUNT(*)
INTO v_overlap_count
FROM (
SELECT 1
FROM dds.dim_routes AS d1
JOIN dds.dim_routes AS d2
ON d1.route_bk = d2.route_bk
AND d1.route_sk < d2.route_sk
AND d1.valid_from < COALESCE(d2.valid_to, DATE '9999-12-31')
AND d2.valid_from < COALESCE(d1.valid_to, DATE '9999-12-31')
) AS overlap_rows;
IF v_overlap_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes найдены перекрытия SCD2-интервалов: %',
v_overlap_count;
END IF;
-- Не более одной текущей версии на route_bk.
SELECT COUNT(*)
INTO v_dup_current
FROM (
SELECT route_bk
FROM dds.dim_routes
WHERE valid_to IS NULL
GROUP BY route_bk
HAVING COUNT(*) > 1
) AS d;
IF v_dup_current <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes найдены route_bk с > 1 текущей версией: %',
v_dup_current;
END IF;
-- Покрытие ODS: все route_no имеют хотя бы одну версию в DDS.
SELECT COUNT(*)
INTO v_missing_count
FROM (SELECT DISTINCT route_no FROM ods.routes) AS o
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_routes AS d
WHERE d.route_bk = o.route_no
);
IF v_missing_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes отсутствуют маршруты из ODS: %',
v_missing_count;
END IF;
-- Current-срез DDS не содержит route_bk, которых нет в ODS.
SELECT COUNT(*)
INTO v_orphan_current
FROM (
SELECT DISTINCT route_bk
FROM dds.dim_routes
WHERE valid_to IS NULL
) AS d
WHERE NOT EXISTS (
SELECT 1
FROM ods.routes AS o
WHERE o.route_no = d.route_bk
);
IF v_orphan_current <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в current-срезе dds.dim_routes есть route_bk вне ODS: %',
v_orphan_current;
END IF;
-- Обязательные поля.
SELECT COUNT(*)
INTO v_null_count
FROM dds.dim_routes
WHERE route_sk IS NULL
OR route_bk IS NULL
OR route_bk = ''
OR departure_airport IS NULL
OR departure_airport = ''
OR arrival_airport IS NULL
OR arrival_airport = ''
OR airplane_code IS NULL
OR airplane_code = ''
OR hashdiff IS NULL
OR hashdiff = ''
OR valid_from IS NULL
OR created_at IS NULL
OR updated_at IS NULL
OR _load_id IS NULL
OR _load_id = ''
OR _load_ts IS NULL;
IF v_null_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_routes найдены NULL обязательные поля: %',
v_null_count;
END IF;
RAISE NOTICE
'DQ PASSED: dds.dim_routes ок, строк=% (версий)',
v_row_count;
END $$;
+130
View File
@@ -0,0 +1,130 @@
-- Загрузка DDS dim_routes: SCD2 с hashdiff.
-- Statement 1: Закрыть устаревшие версии (valid_to = текущая дата).
WITH src AS (
SELECT
route_no,
departure_airport,
arrival_airport,
airplane_code,
days_of_week,
departure_time,
duration,
md5(
COALESCE(departure_airport, '') || '|' ||
COALESCE(arrival_airport, '') || '|' ||
COALESCE(airplane_code, '') || '|' ||
COALESCE(days_of_week, '') || '|' ||
COALESCE(departure_time::TEXT, '') || '|' ||
COALESCE(duration::TEXT, '')
) AS hashdiff,
ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn
FROM ods.routes
)
UPDATE dds.dim_routes AS d
SET valid_to = CURRENT_DATE,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM src AS s
WHERE s.rn = 1
AND d.route_bk = s.route_no
AND d.valid_to IS NULL
AND d.hashdiff <> s.hashdiff;
-- Statement 1.1: Закрыть "исчезнувшие" маршруты.
WITH src AS (
SELECT
route_no,
ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn
FROM ods.routes
)
UPDATE dds.dim_routes AS d
SET valid_to = CURRENT_DATE,
updated_at = now(),
_load_id = '{{ run_id }}',
_load_ts = now()
WHERE d.valid_to IS NULL
AND NOT EXISTS (
SELECT 1
FROM src AS s
WHERE s.rn = 1
AND s.route_no = d.route_bk
);
-- Statement 2: Вставить новые версии (для изменённых и новых route_no).
WITH src AS (
SELECT
route_no,
departure_airport,
arrival_airport,
airplane_code,
days_of_week,
departure_time,
duration,
md5(
COALESCE(departure_airport, '') || '|' ||
COALESCE(arrival_airport, '') || '|' ||
COALESCE(airplane_code, '') || '|' ||
COALESCE(days_of_week, '') || '|' ||
COALESCE(departure_time::TEXT, '') || '|' ||
COALESCE(duration::TEXT, '')
) AS hashdiff,
ROW_NUMBER() OVER (PARTITION BY route_no ORDER BY validity DESC) AS rn
FROM ods.routes
),
max_sk AS (
SELECT COALESCE(MAX(route_sk), 0) AS v
FROM dds.dim_routes
)
INSERT INTO dds.dim_routes (
route_sk,
route_bk,
departure_airport,
arrival_airport,
airplane_code,
days_of_week,
departure_time,
duration,
hashdiff,
valid_from,
valid_to,
created_at,
updated_at,
_load_id,
_load_ts
)
SELECT
(SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.route_no)::INTEGER,
s.route_no,
s.departure_airport,
s.arrival_airport,
s.airplane_code,
s.days_of_week,
s.departure_time,
s.duration,
s.hashdiff,
CASE
WHEN EXISTS (
SELECT 1
FROM dds.dim_routes AS d2
WHERE d2.route_bk = s.route_no
) THEN CURRENT_DATE
ELSE '1900-01-01'::DATE
END AS valid_from,
NULL,
now(),
now(),
'{{ run_id }}',
now()
FROM src AS s
WHERE s.rn = 1
AND NOT EXISTS (
SELECT 1
FROM dds.dim_routes AS d
WHERE d.route_bk = s.route_no
AND d.valid_to IS NULL
AND d.hashdiff = s.hashdiff
);
ANALYZE dds.dim_routes;
+13
View File
@@ -0,0 +1,13 @@
-- DDL для DDS-слоя по таблице dim_tariffs (SCD1-измерение).
CREATE SCHEMA IF NOT EXISTS dds;
CREATE TABLE IF NOT EXISTS dds.dim_tariffs (
tariff_sk INTEGER NOT NULL,
fare_conditions TEXT NOT NULL,
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()
)
DISTRIBUTED BY (tariff_sk);
+98
View File
@@ -0,0 +1,98 @@
-- DQ для DDS dim_tariffs.
DO $$
DECLARE
v_row_count BIGINT;
v_src_count BIGINT;
v_dup_sk BIGINT;
v_dup_bk BIGINT;
v_missing_bk BIGINT;
v_null_count BIGINT;
BEGIN
-- Для инкрементальных периодов без новых сегментов допускаем пустую dim_tariffs.
SELECT COUNT(*)
INTO v_row_count
FROM dds.dim_tariffs;
SELECT COUNT(DISTINCT fare_conditions)
INTO v_src_count
FROM ods.segments
WHERE fare_conditions IS NOT NULL
AND fare_conditions <> '';
IF v_row_count = 0 AND v_src_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: dds.dim_tariffs пуста при непустом источнике ods.segments (fare_conditions=%).',
v_src_count;
ELSIF v_row_count = 0 AND v_src_count = 0 THEN
RAISE NOTICE
'DQ PASSED: dds.dim_tariffs пуста, т.к. в ods.segments нет тарифов для загрузки.';
RETURN;
END IF;
-- Нет дублей по SK.
SELECT COUNT(*) - COUNT(DISTINCT tariff_sk)
INTO v_dup_sk
FROM dds.dim_tariffs;
IF v_dup_sk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_tariffs найдены дубликаты tariff_sk: %',
v_dup_sk;
END IF;
-- Нет дублей по BK.
SELECT COUNT(*) - COUNT(DISTINCT fare_conditions)
INTO v_dup_bk
FROM dds.dim_tariffs;
IF v_dup_bk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_tariffs найдены дубликаты fare_conditions: %',
v_dup_bk;
END IF;
-- Покрытие ODS: все fare_conditions из ods.segments есть в DDS.
SELECT COUNT(*)
INTO v_missing_bk
FROM (
SELECT DISTINCT fare_conditions
FROM ods.segments
WHERE fare_conditions IS NOT NULL
AND fare_conditions <> ''
) AS s
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_tariffs AS d
WHERE d.fare_conditions = s.fare_conditions
);
IF v_missing_bk <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_tariffs отсутствуют значения fare_conditions из ods.segments: %',
v_missing_bk;
END IF;
-- Обязательные поля.
SELECT COUNT(*)
INTO v_null_count
FROM dds.dim_tariffs
WHERE tariff_sk IS NULL
OR fare_conditions IS NULL
OR fare_conditions = ''
OR created_at IS NULL
OR updated_at IS NULL
OR _load_id IS NULL
OR _load_id = ''
OR _load_ts IS NULL;
IF v_null_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.dim_tariffs найдены NULL/пустые обязательные поля: %',
v_null_count;
END IF;
RAISE NOTICE
'DQ PASSED: dds.dim_tariffs ок, строк=%',
v_row_count;
END $$;
+36
View File
@@ -0,0 +1,36 @@
-- Загрузка DDS dim_tariffs: SCD1 UPSERT (INSERT новых тарифов).
WITH src AS (
SELECT DISTINCT
s.fare_conditions
FROM ods.segments AS s
WHERE s.fare_conditions IS NOT NULL
AND s.fare_conditions <> ''
),
max_sk AS (
SELECT COALESCE(MAX(tariff_sk), 0) AS v
FROM dds.dim_tariffs
)
INSERT INTO dds.dim_tariffs (
tariff_sk,
fare_conditions,
created_at,
updated_at,
_load_id,
_load_ts
)
SELECT
(SELECT v FROM max_sk) + ROW_NUMBER() OVER (ORDER BY s.fare_conditions)::INTEGER,
s.fare_conditions,
now(),
now(),
'{{ run_id }}',
now()
FROM src AS s
WHERE NOT EXISTS (
SELECT 1
FROM dds.dim_tariffs AS d
WHERE d.fare_conditions = s.fare_conditions
);
ANALYZE dds.dim_tariffs;
+23
View File
@@ -0,0 +1,23 @@
-- DDL для DDS-слоя по таблице fact_flight_sales.
CREATE SCHEMA IF NOT EXISTS dds;
CREATE TABLE IF NOT EXISTS dds.fact_flight_sales (
calendar_sk INTEGER,
departure_airport_sk INTEGER,
arrival_airport_sk INTEGER,
airplane_sk INTEGER,
tariff_sk INTEGER,
passenger_sk INTEGER,
route_sk INTEGER,
book_ref TEXT NOT NULL,
ticket_no TEXT NOT NULL,
flight_id INTEGER NOT NULL,
book_date DATE,
seat_no TEXT,
price NUMERIC(10,2),
is_boarded BOOLEAN NOT NULL,
_load_id TEXT NOT NULL,
_load_ts TIMESTAMP NOT NULL DEFAULT now()
)
DISTRIBUTED BY (ticket_no);
+151
View File
@@ -0,0 +1,151 @@
-- DQ для DDS fact_flight_sales.
DO $$
DECLARE
v_row_count BIGINT;
v_ods_count BIGINT;
v_dup_count BIGINT;
v_null_passenger BIGINT;
v_null_tariff BIGINT;
v_null_route_related BIGINT;
v_null_calendar BIGINT;
v_null_required BIGINT;
BEGIN
-- Для пустого инкрементального окна (ods.segments) допускаем пустой факт.
SELECT COUNT(*)
INTO v_ods_count
FROM ods.segments;
SELECT COUNT(*)
INTO v_row_count
FROM dds.fact_flight_sales;
IF v_ods_count = 0 THEN
IF v_row_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: ods.segments пустая, но в dds.fact_flight_sales есть строки: %',
v_row_count;
END IF;
RAISE NOTICE
'DQ PASSED: ods.segments и dds.fact_flight_sales пустые (инкрементальное окно без сегментов).';
RETURN;
END IF;
IF v_row_count = 0 THEN
RAISE EXCEPTION
'DQ FAILED: dds.fact_flight_sales пуста при непустом источнике ods.segments (%).',
v_ods_count;
END IF;
-- Покрытие: количество строк = ods.segments.
IF v_row_count <> v_ods_count THEN
RAISE EXCEPTION
'DQ FAILED: dds.fact_flight_sales (%) <> ods.segments (%). Потеряны строки.',
v_row_count,
v_ods_count;
END IF;
-- Нет дублей по зерну.
SELECT COUNT(*)
INTO v_dup_count
FROM (
SELECT ticket_no, flight_id
FROM dds.fact_flight_sales
GROUP BY ticket_no, flight_id
HAVING COUNT(*) > 1
) AS d;
IF v_dup_count <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в dds.fact_flight_sales дубликаты (ticket_no, flight_id): %',
v_dup_count;
END IF;
-- Ссылочная целостность: passenger_sk.
SELECT COUNT(*)
INTO v_null_passenger
FROM dds.fact_flight_sales
WHERE passenger_sk IS NULL;
IF v_null_passenger <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в fact_flight_sales строки без passenger_sk: %',
v_null_passenger;
END IF;
-- Ссылочная целостность: tariff_sk.
SELECT COUNT(*)
INTO v_null_tariff
FROM dds.fact_flight_sales
WHERE tariff_sk IS NULL;
IF v_null_tariff <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в fact_flight_sales строки без tariff_sk: %',
v_null_tariff;
END IF;
-- Route-related FK: допустимо при аномалиях, фейлим если > 1%.
SELECT COUNT(*)
INTO v_null_route_related
FROM dds.fact_flight_sales
WHERE route_sk IS NULL
OR departure_airport_sk IS NULL
OR arrival_airport_sk IS NULL
OR airplane_sk IS NULL;
IF v_null_route_related > 0 THEN
IF v_null_route_related * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN
RAISE EXCEPTION
'DQ FAILED: в fact_flight_sales слишком много строк с NULL в route-related FK: % (>1%%)',
v_null_route_related;
ELSE
RAISE NOTICE
'DQ WARNING: в fact_flight_sales строк с NULL в route-related FK: % (<=1%%, допустимо)',
v_null_route_related;
END IF;
END IF;
-- Calendar: допустимо если scheduled_departure IS NULL, фейлим если > 1%.
SELECT COUNT(*)
INTO v_null_calendar
FROM dds.fact_flight_sales
WHERE calendar_sk IS NULL;
IF v_null_calendar > 0 THEN
IF v_null_calendar * 100.0 / NULLIF(v_row_count, 0) > 1.0 THEN
RAISE EXCEPTION
'DQ FAILED: в fact_flight_sales слишком много строк без calendar_sk: % (>1%%)',
v_null_calendar;
ELSE
RAISE NOTICE
'DQ WARNING: в fact_flight_sales строк без calendar_sk: % (<=1%%, допустимо)',
v_null_calendar;
END IF;
END IF;
-- Обязательные поля.
SELECT COUNT(*)
INTO v_null_required
FROM dds.fact_flight_sales
WHERE book_ref IS NULL
OR book_ref = ''
OR ticket_no IS NULL
OR ticket_no = ''
OR flight_id IS NULL
OR is_boarded IS NULL
OR _load_id IS NULL
OR _load_id = ''
OR _load_ts IS NULL;
IF v_null_required <> 0 THEN
RAISE EXCEPTION
'DQ FAILED: в fact_flight_sales NULL обязательные поля: %',
v_null_required;
END IF;
RAISE NOTICE
'DQ PASSED: dds.fact_flight_sales ок, строк=%',
v_row_count;
END $$;
+111
View File
@@ -0,0 +1,111 @@
-- Загрузка DDS fact_flight_sales: инкрементальный UPSERT по (ticket_no, flight_id).
-- Statement 1: UPDATE существующих строк факта.
-- Обновляем только мутабельные поля; SK измерений не перезаписываем.
UPDATE dds.fact_flight_sales AS f
SET seat_no = bp.seat_no,
price = seg.segment_amount,
is_boarded = (bp.ticket_no IS NOT NULL),
_load_id = '{{ run_id }}',
_load_ts = now()
FROM ods.segments AS seg
LEFT JOIN ods.boarding_passes AS bp
ON bp.ticket_no = seg.ticket_no
AND bp.flight_id = seg.flight_id
WHERE f.ticket_no = seg.ticket_no
AND f.flight_id = seg.flight_id
AND (
f.is_boarded IS DISTINCT FROM (bp.ticket_no IS NOT NULL)
OR f.price IS DISTINCT FROM seg.segment_amount
OR f.seat_no IS DISTINCT FROM bp.seat_no
);
-- Statement 2: INSERT новых строк факта.
-- Dimension SK фиксируются на момент вставки (point-in-time для SCD2 routes).
WITH fact_src AS (
SELECT
seg.ticket_no,
seg.flight_id,
cal.calendar_sk,
dep.airport_sk AS departure_airport_sk,
arr.airport_sk AS arrival_airport_sk,
ap.airplane_sk,
tar.tariff_sk,
pax.passenger_sk,
rte.route_sk,
tkt.book_ref,
bkg.book_date::DATE AS book_date,
bp.seat_no,
seg.segment_amount AS price,
(bp.ticket_no IS NOT NULL) AS is_boarded
FROM ods.segments AS seg
JOIN ods.tickets AS tkt
ON tkt.ticket_no = seg.ticket_no
JOIN ods.bookings AS bkg
ON bkg.book_ref = tkt.book_ref
JOIN ods.flights AS flt
ON flt.flight_id = seg.flight_id
LEFT JOIN dds.dim_routes AS rte
ON rte.route_bk = flt.route_no
AND flt.scheduled_departure::DATE >= rte.valid_from
AND (rte.valid_to IS NULL OR flt.scheduled_departure::DATE < rte.valid_to)
LEFT JOIN dds.dim_calendar AS cal
ON cal.date_actual = flt.scheduled_departure::DATE
LEFT JOIN dds.dim_airports AS dep
ON dep.airport_bk = rte.departure_airport
LEFT JOIN dds.dim_airports AS arr
ON arr.airport_bk = rte.arrival_airport
LEFT JOIN dds.dim_airplanes AS ap
ON ap.airplane_bk = rte.airplane_code
LEFT JOIN dds.dim_tariffs AS tar
ON tar.fare_conditions = seg.fare_conditions
LEFT JOIN dds.dim_passengers AS pax
ON pax.passenger_bk = tkt.passenger_id
LEFT JOIN ods.boarding_passes AS bp
ON bp.ticket_no = seg.ticket_no
AND bp.flight_id = seg.flight_id
)
INSERT INTO dds.fact_flight_sales (
calendar_sk,
departure_airport_sk,
arrival_airport_sk,
airplane_sk,
tariff_sk,
passenger_sk,
route_sk,
book_ref,
ticket_no,
flight_id,
book_date,
seat_no,
price,
is_boarded,
_load_id,
_load_ts
)
SELECT
s.calendar_sk,
s.departure_airport_sk,
s.arrival_airport_sk,
s.airplane_sk,
s.tariff_sk,
s.passenger_sk,
s.route_sk,
s.book_ref,
s.ticket_no,
s.flight_id,
s.book_date,
s.seat_no,
s.price,
s.is_boarded,
'{{ run_id }}',
now()
FROM fact_src AS s
WHERE NOT EXISTS (
SELECT 1
FROM dds.fact_flight_sales AS f
WHERE f.ticket_no = s.ticket_no
AND f.flight_id = s.flight_id
);
ANALYZE dds.fact_flight_sales;