diff --git a/README.md b/README.md index bed9180..10fe0a7 100644 --- a/README.md +++ b/README.md @@ -96,11 +96,15 @@ SELECT COUNT(*) FROM dm.sales_report; Подробнее про логику DAG и проверки — `docs/bookings_to_gp_stage.md`. -7) Приступайте к заданию: +7) Разберитесь в данных и приступайте к заданию: -Стенд работает, данные загружены. Переходите к -[учебным заданиям](docs/assignment/README.md) — там описан путь -от изучения эталона до полной реализации DWH. +Стенд работает, данные загружены. Откройте +[руководство о структуре DWH](docs/design/db_schema.md): на примере поездки +оно проведет от билетов и сегментов к факту и витрине. Рядом держите +[интерактивную карту](docs/design/architecture-map.html) — локальный файл +`docs/design/architecture-map.html` нужно открыть в браузере; в Gitea HTML +показывается как исходный текст. Затем переходите к +[учебным заданиям](docs/assignment/README.md). ## DAG-и в стенде @@ -151,7 +155,8 @@ make clean # полный reset: удалить контейнер ## Документация - [Учебные задания](docs/assignment/README.md) -- [Схема DWH: четыре уровня приближения](docs/design/db_schema.md) +- [Структура DWH: от бронирования до витрины](docs/design/db_schema.md) +- [Интерактивная карта таблиц и связей](docs/design/architecture-map.html) - [Как читать пайплайн по скрипту загрузки](docs/design/reading_the_pipeline.md) - [План тестирования/проверок и негативные кейсы](TESTING.md) - [Дополнительные заметки и технические детали](docs/README.md) diff --git a/docs/README.md b/docs/README.md index 6808ab1..04ecee3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,8 @@ ## Быстрый путь (для менти) - [Быстрый старт и команды](../README.md) +- [Структура DWH: от бронирования до витрины](design/db_schema.md) +- [Интерактивная карта таблиц и связей](design/architecture-map.html) - [Учебные задания](assignment/README.md) - [Как читать пайплайн по скрипту загрузки](design/reading_the_pipeline.md) - [План тестирования и проверки](../TESTING.md) @@ -13,10 +15,14 @@ - [Учебный DAG: ods -> dds](bookings_to_gp_dds.md) - [Учебный DAG: dds -> dm](bookings_to_gp_dm.md) +Карту открывайте из локальной копии: `docs/design/architecture-map.html` в браузере. +Сервер не нужен. Просмотр исходного HTML в Gitea не показывает карту. +[Руководство](design/db_schema.md#как-открыть-карту) объясняет открытие и адресные ссылки. + ## Дизайн (`design/`) - [Единые конвенции нейминга DWH (служебные поля и SCD)](design/naming_conventions.md) -- [Схема БД всех слоёв DWH](design/db_schema.md) +- [Структура DWH и маршрут по карте](design/db_schema.md) - [Как читать пайплайн по скрипту загрузки](design/reading_the_pipeline.md) - [Дизайн-документ STG](design/bookings_stg_design.md) - [Дизайн-документ ODS](design/bookings_ods_design.md) diff --git a/docs/assignment/README.md b/docs/assignment/README.md index d608434..d779a8f 100644 --- a/docs/assignment/README.md +++ b/docs/assignment/README.md @@ -11,7 +11,7 @@ ## С чего начать чтение -Сначала посмотрите [схему DWH по уровням приближения](../design/db_schema.md). +Сначала посмотрите [руководство о структуре DWH и карту](../design/db_schema.md). Пока пишете свой слой, держите рядом [метод чтения скрипта загрузки](../design/reading_the_pipeline.md). Для `*_dq.sql` сверяйтесь с [классами DQ-проверок](../reference/dq_taxonomy.md). diff --git a/docs/design/architecture-map.html b/docs/design/architecture-map.html new file mode 100644 index 0000000..92f577b --- /dev/null +++ b/docs/design/architecture-map.html @@ -0,0 +1,5197 @@ + + + + + + + Карта DWH авиаперевозок + + + +
+

+

+ +
+
+
+

+
+ Сплошная рамка: готовоПунктирная рамка: задание + Синий: источникиЗеленый: потребителиОранжевый: выбор +
+
+
+
+ +
+ +
+ + + + diff --git a/docs/design/architecture-map/README.md b/docs/design/architecture-map/README.md new file mode 100644 index 0000000..0107994 --- /dev/null +++ b/docs/design/architecture-map/README.md @@ -0,0 +1,60 @@ +# Исходники карты DWH + +Готовый файл - [../architecture-map.html](../architecture-map.html). +Откройте его локально в браузере. Сервер и интернет не требуются; +SQL и Markdown открываются из соседних каталогов репозитория. +Учебный маршрут находится в [руководстве о структуре](../db_schema.md). + +## Редактирование и сборка + +- `map.json` - единственный каталог объектов, полей, связей и текстов карточек. +- `template.html` - интерфейс. SVG, карточки, постоянная раскладка и выделение + адаптированы из `clickstream-data-platform/docs/handbook/architecture-map/template.html`. +- `render.py` - сборка автономного HTML и статические проверки. Нужны uv и Python + 3.11 или новее; пакеты Python и JavaScript не требуются. JSON заменяет YAML + образца, чтобы сборка обходилась стандартной библиотекой. + +Из корня репозитория: + +```bash +uv run docs/design/architecture-map/render.py +uv run docs/design/architecture-map/render.py --check +``` + +Правьте исходники, затем пересобирайте HTML. `--check` сверяет: +имена объектов с DDL/PXF, важные поля и поля связей, статусы загрузок и DQ, +входы готовых SQL со стрелками потока, файлы и разделы по ссылкам, +уникальность идентификаторов и позиций, свежесть HTML. +Проверка входов учитывает именованные таблицы в FROM/JOIN; +смысл соединений нужно сверять при изменении SQL вручную. + +Внешние `stg.*_ext` представлены в карточках STG, поскольку это входы чтения, +а не еще одна копия данных. Поля источника сверяются с контрактом PXF. +Для заглушек используются DDL и `docs/assignment/analyst_spec.md`. +Новые предположения о поведении загрузок в карточки не добавляйте. + +## Адреса объектов + +`../architecture-map.html#node=ods.airplanes` выбирает таблицу. +Дополнение `&view=keys` включает связи по ключам; без него показан поток данных. +Идентификатор равен полному имени таблицы и не зависит от позиции в карте. +Неизвестный адрес возвращает обзор с сообщением. Назад/вперед в браузере +восстанавливают предыдущий выбор и вид связей. + +## Проверка в браузере + +Статические проверки не заменяют этот проход: + +1. Откройте готовый HTML с диска. Убедитесь, что видны пять слоев, + имена таблиц и пометки заданий; ошибок JavaScript нет. +2. Выберите `bookings.tickets`, затем `ods.segments` и `dds.fact_flight_sales`. + Проверьте источники, потребителей и карточки связей. Снимите выделение + повторным выбором, кнопкой "Общий вид" и клавишей Esc. +3. Для выбранных билета, факта и `dds.dim_airplanes` переключите оба вида: + выбор, позиции и прокрутка карты должны сохраниться. +4. У факта откройте обе роли `dim_airports`, путь через `ods.routes` + и выбор версии `dim_routes`. Проверьте отдельные карточки связей. +5. Откройте адрес объекта, обновите страницу, проверьте назад/вперед. + Откройте DDL, загрузку, DQ и пункт задания из карточки. +6. Проверьте выбор клавишами Enter/пробел и вид на узком экране. + На узком экране карта прокручивается, карточка находится под ней. diff --git a/docs/design/architecture-map/map.json b/docs/design/architecture-map/map.json new file mode 100644 index 0000000..ca16d32 --- /dev/null +++ b/docs/design/architecture-map/map.json @@ -0,0 +1,4749 @@ +{ + "title": "Хранилище авиаперевозок", + "lead": "От бронирования до витрины: выберите таблицу или связь.", + "layers": [ + { + "id": "source", + "title": "Источник", + "caption": "Postgres · demo" + }, + { + "id": "stg", + "title": "STG", + "caption": "Сырые строки · Greenplum" + }, + { + "id": "ods", + "title": "ODS", + "caption": "Типы и текущее состояние" + }, + { + "id": "dds", + "title": "DDS", + "caption": "Измерения и факт" + }, + { + "id": "dm", + "title": "DM", + "caption": "Ответы на бизнес-вопросы" + } + ], + "nodes": [ + { + "id": "bookings.bookings", + "name": "bookings.bookings", + "layer": "source", + "order": 0, + "what": "Бронирование объединяет билеты одной покупки.", + "grain": "Одно бронирование.", + "keys": "`book_ref` — логический ключ строки источника.", + "fields": [ + { + "name": "book_ref", + "meaning": "номер бронирования" + }, + { + "name": "book_date", + "meaning": "момент бронирования" + }, + { + "name": "total_amount", + "meaning": "сумма бронирования целиком" + } + ], + "fill": "Демо-БД восстанавливается из seed-дампа; генератор добавляет учебные дни. PXF читает ее через `stg.bookings_ext`.", + "status": "source", + "links": [ + { + "label": "Источник и генератор", + "url": "../../bookings/README.md" + }, + { + "label": "DDL входа PXF", + "url": "../../sql/stg/bookings_ddl.sql" + } + ], + "parts": [] + }, + { + "id": "stg.bookings", + "name": "stg.bookings", + "layer": "stg", + "order": 0, + "what": "Бронирование объединяет билеты одной покупки. Здесь бизнес-поля хранятся как TEXT.", + "grain": "Одно бронирование. Строка относится к загрузке STG.", + "keys": "Бизнес-ключ: `book_ref`. Для хранимой строки учитывайте также `_load_id`: снимки разных запусков сосуществуют.", + "fields": [ + { + "name": "book_ref", + "meaning": "номер бронирования" + }, + { + "name": "book_date", + "meaning": "момент бронирования" + }, + { + "name": "total_amount", + "meaning": "сумма бронирования целиком" + }, + { + "name": "event_ts", + "meaning": "опорное время инкремента" + }, + { + "name": "_load_id", + "meaning": "идентификатор загрузки" + }, + { + "name": "_load_ts", + "meaning": "время записи в STG" + } + ], + "fill": "Добавление строк по `event_ts` предыдущих загрузок. Опорное время — `book_date` бронирования. Повтор защищен ключом строки внутри `_load_id`.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/stg/bookings_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/stg/bookings_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/stg/bookings_dq.sql" + } + ], + "parts": [ + "stg.bookings_ext" + ] + }, + { + "id": "ods.bookings", + "name": "ods.bookings", + "layer": "ods", + "order": 0, + "what": "Бронирование объединяет билеты одной покупки.", + "grain": "Одно бронирование.", + "keys": "`book_ref` — логический ключ текущей строки.", + "fields": [ + { + "name": "book_ref", + "meaning": "номер бронирования" + }, + { + "name": "book_date", + "meaning": "момент бронирования" + }, + { + "name": "total_amount", + "meaning": "сумма бронирования целиком" + } + ], + "fill": "Дельта из STG по `_load_ts`, приведение типов, выбор последней строки по бизнес-ключу; UPDATE изменившихся и INSERT новых. Время и идентификатор загрузки сохраняются из STG.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/ods/bookings_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/ods/bookings_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/ods/bookings_dq.sql" + } + ], + "parts": [] + }, + { + "id": "bookings.tickets", + "name": "bookings.tickets", + "layer": "source", + "order": 1, + "what": "Билет связывает пассажира с бронированием. В билете может быть несколько сегментов.", + "grain": "Один билет пассажира.", + "keys": "`ticket_no` — логический ключ строки источника.", + "fields": [ + { + "name": "ticket_no", + "meaning": "номер билета" + }, + { + "name": "book_ref", + "meaning": "бронирование" + }, + { + "name": "passenger_id", + "meaning": "идентификатор пассажира" + }, + { + "name": "passenger_name", + "meaning": "имя пассажира" + }, + { + "name": "outbound", + "meaning": "признак направления поездки" + } + ], + "fill": "Демо-БД восстанавливается из seed-дампа; генератор добавляет учебные дни. PXF читает ее через `stg.tickets_ext`.", + "status": "source", + "links": [ + { + "label": "Источник и генератор", + "url": "../../bookings/README.md" + }, + { + "label": "DDL входа PXF", + "url": "../../sql/stg/tickets_ddl.sql" + } + ], + "parts": [] + }, + { + "id": "stg.tickets", + "name": "stg.tickets", + "layer": "stg", + "order": 1, + "what": "Билет связывает пассажира с бронированием. В билете может быть несколько сегментов. Здесь бизнес-поля хранятся как TEXT.", + "grain": "Один билет пассажира. Строка относится к загрузке STG.", + "keys": "Бизнес-ключ: `ticket_no`. Загрузчик исключает повторный `ticket_no` во всей таблице.", + "fields": [ + { + "name": "ticket_no", + "meaning": "номер билета" + }, + { + "name": "book_ref", + "meaning": "бронирование" + }, + { + "name": "passenger_id", + "meaning": "идентификатор пассажира" + }, + { + "name": "passenger_name", + "meaning": "имя пассажира" + }, + { + "name": "outbound", + "meaning": "признак направления поездки" + }, + { + "name": "event_ts", + "meaning": "опорное время инкремента" + }, + { + "name": "_load_id", + "meaning": "идентификатор загрузки" + }, + { + "name": "_load_ts", + "meaning": "время записи в STG" + } + ], + "fill": "Добавление строк по `event_ts` предыдущих загрузок. Опорное время — `book_date` бронирования. Уже сохраненный `ticket_no` повторно не вставляется.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/stg/tickets_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/stg/tickets_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/stg/tickets_dq.sql" + } + ], + "parts": [ + "stg.tickets_ext" + ] + }, + { + "id": "ods.tickets", + "name": "ods.tickets", + "layer": "ods", + "order": 1, + "what": "Билет связывает пассажира с бронированием. В билете может быть несколько сегментов.", + "grain": "Один билет пассажира.", + "keys": "`ticket_no` — логический ключ текущей строки.", + "fields": [ + { + "name": "ticket_no", + "meaning": "номер билета" + }, + { + "name": "book_ref", + "meaning": "бронирование" + }, + { + "name": "passenger_id", + "meaning": "идентификатор пассажира" + }, + { + "name": "passenger_name", + "meaning": "имя пассажира" + }, + { + "name": "is_outbound", + "meaning": "признак направления поездки" + } + ], + "fill": "Дельта из STG по `_load_ts`, приведение типов, выбор последней строки по бизнес-ключу; UPDATE изменившихся и INSERT новых. Время и идентификатор загрузки сохраняются из STG. `outbound` становится `is_outbound` (BOOLEAN).", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/ods/tickets_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/ods/tickets_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/ods/tickets_dq.sql" + } + ], + "parts": [] + }, + { + "id": "bookings.segments", + "name": "bookings.segments", + "layer": "source", + "order": 2, + "what": "Сегмент связывает билет с конкретным рейсом и хранит цену этого перелета.", + "grain": "Один перелет по одному билету.", + "keys": "`ticket_no` + `flight_id` — логический ключ строки источника.", + "fields": [ + { + "name": "ticket_no", + "meaning": "билет" + }, + { + "name": "flight_id", + "meaning": "рейс" + }, + { + "name": "fare_conditions", + "meaning": "класс обслуживания" + }, + { + "name": "price", + "meaning": "стоимость сегмента" + } + ], + "fill": "Демо-БД восстанавливается из seed-дампа; генератор добавляет учебные дни. PXF читает ее через `stg.segments_ext`.", + "status": "source", + "links": [ + { + "label": "Источник и генератор", + "url": "../../bookings/README.md" + }, + { + "label": "DDL входа PXF", + "url": "../../sql/stg/segments_ddl.sql" + } + ], + "parts": [] + }, + { + "id": "stg.segments", + "name": "stg.segments", + "layer": "stg", + "order": 2, + "what": "Сегмент связывает билет с конкретным рейсом и хранит цену этого перелета. Здесь бизнес-поля хранятся как TEXT.", + "grain": "Один перелет по одному билету. Строка относится к загрузке STG.", + "keys": "Бизнес-ключ: `ticket_no` + `flight_id`. Для хранимой строки учитывайте также `_load_id`: снимки разных запусков сосуществуют.", + "fields": [ + { + "name": "ticket_no", + "meaning": "билет" + }, + { + "name": "flight_id", + "meaning": "рейс" + }, + { + "name": "fare_conditions", + "meaning": "класс обслуживания" + }, + { + "name": "price", + "meaning": "стоимость сегмента" + }, + { + "name": "event_ts", + "meaning": "опорное время инкремента" + }, + { + "name": "_load_id", + "meaning": "идентификатор загрузки" + }, + { + "name": "_load_ts", + "meaning": "время записи в STG" + } + ], + "fill": "Добавление строк по `event_ts` предыдущих загрузок. Опорное время — `book_date` бронирования. Повтор защищен ключом строки внутри `_load_id`.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/stg/segments_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/stg/segments_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/stg/segments_dq.sql" + } + ], + "parts": [ + "stg.segments_ext" + ] + }, + { + "id": "ods.segments", + "name": "ods.segments", + "layer": "ods", + "order": 2, + "what": "Сегмент связывает билет с конкретным рейсом и хранит цену этого перелета.", + "grain": "Один перелет по одному билету.", + "keys": "`ticket_no` + `flight_id` — логический ключ текущей строки.", + "fields": [ + { + "name": "ticket_no", + "meaning": "билет" + }, + { + "name": "flight_id", + "meaning": "рейс" + }, + { + "name": "fare_conditions", + "meaning": "класс обслуживания" + }, + { + "name": "amount", + "meaning": "стоимость сегмента" + } + ], + "fill": "Дельта из STG по `_load_ts`, приведение типов, выбор последней строки по бизнес-ключу; UPDATE изменившихся и INSERT новых. Время и идентификатор загрузки сохраняются из STG. `stg.segments.price` становится `amount` (NUMERIC).", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/ods/segments_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/ods/segments_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/ods/segments_dq.sql" + } + ], + "parts": [] + }, + { + "id": "bookings.flights", + "name": "bookings.flights", + "layer": "source", + "order": 3, + "what": "Рейс представляет конкретный вылет по маршруту. Один маршрут выполняется многократно.", + "grain": "Один рейс с собственным расписанием и статусом.", + "keys": "`flight_id` — логический ключ строки источника.", + "fields": [ + { + "name": "flight_id", + "meaning": "идентификатор рейса" + }, + { + "name": "route_no", + "meaning": "маршрут" + }, + { + "name": "scheduled_departure", + "meaning": "плановый момент вылета" + }, + { + "name": "scheduled_arrival", + "meaning": "плановый момент прилета" + }, + { + "name": "status", + "meaning": "состояние рейса" + } + ], + "fill": "Демо-БД восстанавливается из seed-дампа; генератор добавляет учебные дни. PXF читает ее через `stg.flights_ext`.", + "status": "source", + "links": [ + { + "label": "Источник и генератор", + "url": "../../bookings/README.md" + }, + { + "label": "DDL входа PXF", + "url": "../../sql/stg/flights_ddl.sql" + } + ], + "parts": [] + }, + { + "id": "stg.flights", + "name": "stg.flights", + "layer": "stg", + "order": 3, + "what": "Рейс представляет конкретный вылет по маршруту. Один маршрут выполняется многократно. Здесь бизнес-поля хранятся как TEXT.", + "grain": "Один рейс с собственным расписанием и статусом. Строка относится к загрузке STG.", + "keys": "Бизнес-ключ: `flight_id`. Для хранимой строки учитывайте также `_load_id`: снимки разных запусков сосуществуют.", + "fields": [ + { + "name": "flight_id", + "meaning": "идентификатор рейса" + }, + { + "name": "route_no", + "meaning": "маршрут" + }, + { + "name": "scheduled_departure", + "meaning": "плановый момент вылета" + }, + { + "name": "scheduled_arrival", + "meaning": "плановый момент прилета" + }, + { + "name": "status", + "meaning": "состояние рейса" + }, + { + "name": "event_ts", + "meaning": "опорное время инкремента" + }, + { + "name": "_load_id", + "meaning": "идентификатор загрузки" + }, + { + "name": "_load_ts", + "meaning": "время записи в STG" + } + ], + "fill": "Добавление строк по `event_ts` предыдущих загрузок. Опорное время — `scheduled_departure`. Повтор защищен ключом строки внутри `_load_id`.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/stg/flights_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/stg/flights_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/stg/flights_dq.sql" + } + ], + "parts": [ + "stg.flights_ext" + ] + }, + { + "id": "ods.flights", + "name": "ods.flights", + "layer": "ods", + "order": 3, + "what": "Рейс представляет конкретный вылет по маршруту. Один маршрут выполняется многократно.", + "grain": "Один рейс с собственным расписанием и статусом.", + "keys": "`flight_id` — логический ключ текущей строки.", + "fields": [ + { + "name": "flight_id", + "meaning": "идентификатор рейса" + }, + { + "name": "route_no", + "meaning": "маршрут" + }, + { + "name": "scheduled_departure", + "meaning": "плановый момент вылета" + }, + { + "name": "scheduled_arrival", + "meaning": "плановый момент прилета" + }, + { + "name": "status", + "meaning": "состояние рейса" + } + ], + "fill": "Дельта из STG по `_load_ts`, приведение типов, выбор последней строки по бизнес-ключу; UPDATE изменившихся и INSERT новых. Время и идентификатор загрузки сохраняются из STG. Дополнительно берутся рейсы из истории STG, на которые ссылаются новые `stg.segments`.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/ods/flights_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/ods/flights_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/ods/flights_dq.sql" + } + ], + "parts": [] + }, + { + "id": "bookings.boarding_passes", + "name": "bookings.boarding_passes", + "layer": "source", + "order": 4, + "what": "Посадочный талон относится к сегменту билета. Его наличие используется как признак посадки в факте.", + "grain": "Один посадочный талон на билет и рейс.", + "keys": "`ticket_no` + `flight_id` — логический ключ строки источника.", + "fields": [ + { + "name": "ticket_no", + "meaning": "билет" + }, + { + "name": "flight_id", + "meaning": "рейс" + }, + { + "name": "seat_no", + "meaning": "место в салоне" + }, + { + "name": "boarding_no", + "meaning": "номер посадочного талона" + }, + { + "name": "boarding_time", + "meaning": "время посадки" + } + ], + "fill": "Демо-БД восстанавливается из seed-дампа; генератор добавляет учебные дни. PXF читает ее через `stg.boarding_passes_ext`.", + "status": "source", + "links": [ + { + "label": "Источник и генератор", + "url": "../../bookings/README.md" + }, + { + "label": "DDL входа PXF", + "url": "../../sql/stg/boarding_passes_ddl.sql" + } + ], + "parts": [] + }, + { + "id": "stg.boarding_passes", + "name": "stg.boarding_passes", + "layer": "stg", + "order": 4, + "what": "Посадочный талон относится к сегменту билета. Его наличие используется как признак посадки в факте. Здесь бизнес-поля хранятся как TEXT.", + "grain": "Один посадочный талон на билет и рейс. Строка относится к загрузке STG.", + "keys": "Бизнес-ключ: `ticket_no` + `flight_id`. Для хранимой строки учитывайте также `_load_id`: снимки разных запусков сосуществуют.", + "fields": [ + { + "name": "ticket_no", + "meaning": "билет" + }, + { + "name": "flight_id", + "meaning": "рейс" + }, + { + "name": "seat_no", + "meaning": "место в салоне" + }, + { + "name": "boarding_no", + "meaning": "номер посадочного талона" + }, + { + "name": "boarding_time", + "meaning": "время посадки" + }, + { + "name": "event_ts", + "meaning": "опорное время инкремента" + }, + { + "name": "_load_id", + "meaning": "идентификатор загрузки" + }, + { + "name": "_load_ts", + "meaning": "время записи в STG" + } + ], + "fill": "Добавление строк по `event_ts` предыдущих загрузок. Опорное время — `book_date` бронирования. Повтор защищен ключом строки внутри `_load_id`.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/stg/boarding_passes_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/stg/boarding_passes_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/stg/boarding_passes_dq.sql" + } + ], + "parts": [ + "stg.boarding_passes_ext" + ] + }, + { + "id": "ods.boarding_passes", + "name": "ods.boarding_passes", + "layer": "ods", + "order": 4, + "what": "Посадочный талон относится к сегменту билета. Его наличие используется как признак посадки в факте.", + "grain": "Один посадочный талон на билет и рейс.", + "keys": "`ticket_no` + `flight_id` — логический ключ текущей строки.", + "fields": [ + { + "name": "ticket_no", + "meaning": "билет" + }, + { + "name": "flight_id", + "meaning": "рейс" + }, + { + "name": "seat_no", + "meaning": "место в салоне" + }, + { + "name": "boarding_no", + "meaning": "номер посадочного талона" + }, + { + "name": "boarding_time", + "meaning": "время посадки" + } + ], + "fill": "Дельта из STG по `_load_ts`, приведение типов, выбор последней строки по бизнес-ключу; UPDATE изменившихся и INSERT новых. Время и идентификатор загрузки сохраняются из STG.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/ods/boarding_passes_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/ods/boarding_passes_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/ods/boarding_passes_dq.sql" + } + ], + "parts": [] + }, + { + "id": "bookings.routes", + "name": "bookings.routes", + "layer": "source", + "order": 5, + "what": "Маршрут задает аэропорты, модель самолета и расписание на период действия.", + "grain": "Один вариант расписания маршрута.", + "keys": "`route_no` + `validity` — логический ключ строки источника.", + "fields": [ + { + "name": "route_no", + "meaning": "номер маршрута" + }, + { + "name": "validity", + "meaning": "период действия исходного расписания" + }, + { + "name": "departure_airport", + "meaning": "код аэропорта вылета" + }, + { + "name": "arrival_airport", + "meaning": "код аэропорта прилета" + }, + { + "name": "airplane_code", + "meaning": "код модели самолета" + }, + { + "name": "scheduled_time", + "meaning": "время вылета по расписанию" + } + ], + "fill": "Демо-БД восстанавливается из seed-дампа; генератор добавляет учебные дни. PXF читает ее через `stg.routes_ext`.", + "status": "source", + "links": [ + { + "label": "Источник и генератор", + "url": "../../bookings/README.md" + }, + { + "label": "DDL входа PXF", + "url": "../../sql/stg/routes_ddl.sql" + } + ], + "parts": [] + }, + { + "id": "stg.routes", + "name": "stg.routes", + "layer": "stg", + "order": 5, + "what": "Маршрут задает аэропорты, модель самолета и расписание на период действия. Здесь бизнес-поля хранятся как TEXT.", + "grain": "Один вариант расписания маршрута. Строка относится к загрузке STG.", + "keys": "Бизнес-ключ: `route_no` + `validity`. Для хранимой строки учитывайте также `_load_id`: снимки разных запусков сосуществуют.", + "fields": [ + { + "name": "route_no", + "meaning": "номер маршрута" + }, + { + "name": "validity", + "meaning": "период действия исходного расписания" + }, + { + "name": "departure_airport", + "meaning": "код аэропорта вылета" + }, + { + "name": "arrival_airport", + "meaning": "код аэропорта прилета" + }, + { + "name": "airplane_code", + "meaning": "код модели самолета" + }, + { + "name": "scheduled_time", + "meaning": "время вылета по расписанию" + }, + { + "name": "event_ts", + "meaning": "время снимка (`now()`)" + }, + { + "name": "_load_id", + "meaning": "идентификатор загрузки" + }, + { + "name": "_load_ts", + "meaning": "время записи в STG" + } + ], + "fill": "Полный снимок при каждом новом запуске STG; повтор в том же `_load_id` не добавляет строку повторно.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/stg/routes_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/stg/routes_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/stg/routes_dq.sql" + } + ], + "parts": [ + "stg.routes_ext" + ] + }, + { + "id": "ods.routes", + "name": "ods.routes", + "layer": "ods", + "order": 5, + "what": "Маршрут задает аэропорты, модель самолета и расписание на период действия.", + "grain": "Один вариант расписания маршрута.", + "keys": "`route_no` + `validity` — логический ключ текущей строки.", + "fields": [ + { + "name": "route_no", + "meaning": "номер маршрута" + }, + { + "name": "validity", + "meaning": "период действия исходного расписания" + }, + { + "name": "departure_airport", + "meaning": "код аэропорта вылета" + }, + { + "name": "arrival_airport", + "meaning": "код аэропорта прилета" + }, + { + "name": "airplane_code", + "meaning": "код модели самолета" + }, + { + "name": "departure_time", + "meaning": "время вылета по расписанию" + } + ], + "fill": "Полный снимок из согласованного батча STG (`stg_batch_id`): TRUNCATE + INSERT, одна строка на бизнес-ключ. `scheduled_time` становится `departure_time` (TIME), `days_of_week` — INTEGER[], `duration` — INTERVAL.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/ods/routes_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/ods/routes_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/ods/routes_dq.sql" + } + ], + "parts": [] + }, + { + "id": "bookings.airports_data", + "name": "bookings.airports_data", + "layer": "source", + "order": 6, + "what": "Справочник аэропортов. Один аэропорт может участвовать в разных рейсах как место вылета или прилета.", + "grain": "Один аэропорт.", + "keys": "`airport_code` — логический ключ строки источника.", + "fields": [ + { + "name": "airport_code", + "meaning": "код аэропорта" + }, + { + "name": "airport_name", + "meaning": "название" + }, + { + "name": "city", + "meaning": "город" + }, + { + "name": "timezone", + "meaning": "часовой пояс" + } + ], + "fill": "Демо-БД восстанавливается из seed-дампа; генератор добавляет учебные дни. PXF читает ее через `stg.airports_ext`.", + "status": "source", + "links": [ + { + "label": "Источник и генератор", + "url": "../../bookings/README.md" + }, + { + "label": "DDL входа PXF", + "url": "../../sql/stg/airports_ddl.sql" + } + ], + "parts": [] + }, + { + "id": "stg.airports", + "name": "stg.airports", + "layer": "stg", + "order": 6, + "what": "Справочник аэропортов. Один аэропорт может участвовать в разных рейсах как место вылета или прилета. Здесь бизнес-поля хранятся как TEXT.", + "grain": "Один аэропорт. Строка относится к загрузке STG.", + "keys": "Бизнес-ключ: `airport_code`. Для хранимой строки учитывайте также `_load_id`: снимки разных запусков сосуществуют.", + "fields": [ + { + "name": "airport_code", + "meaning": "код аэропорта" + }, + { + "name": "airport_name", + "meaning": "название" + }, + { + "name": "city", + "meaning": "город" + }, + { + "name": "timezone", + "meaning": "часовой пояс" + }, + { + "name": "event_ts", + "meaning": "время снимка (`now()`)" + }, + { + "name": "_load_id", + "meaning": "идентификатор загрузки" + }, + { + "name": "_load_ts", + "meaning": "время записи в STG" + } + ], + "fill": "Полный снимок при каждом новом запуске STG; повтор в том же `_load_id` не добавляет строку повторно.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/stg/airports_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/stg/airports_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/stg/airports_dq.sql" + } + ], + "parts": [ + "stg.airports_ext" + ] + }, + { + "id": "ods.airports", + "name": "ods.airports", + "layer": "ods", + "order": 6, + "what": "Справочник аэропортов. Один аэропорт может участвовать в разных рейсах как место вылета или прилета.", + "grain": "Один аэропорт.", + "keys": "`airport_code` — логический ключ текущей строки.", + "fields": [ + { + "name": "airport_code", + "meaning": "код аэропорта" + }, + { + "name": "airport_name", + "meaning": "название" + }, + { + "name": "city", + "meaning": "город" + }, + { + "name": "timezone", + "meaning": "часовой пояс" + } + ], + "fill": "Полный снимок из согласованного батча STG (`stg_batch_id`): TRUNCATE + INSERT, одна строка на бизнес-ключ. Из JSON извлекаются русские названия аэропорта, города и страны.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/ods/airports_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/ods/airports_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/ods/airports_dq.sql" + } + ], + "parts": [] + }, + { + "id": "bookings.airplanes_data", + "name": "bookings.airplanes_data", + "layer": "source", + "order": 7, + "what": "Справочник моделей самолетов; бортовых номеров отдельных самолетов здесь нет.", + "grain": "Одна модель самолета.", + "keys": "`airplane_code` — логический ключ строки источника.", + "fields": [ + { + "name": "airplane_code", + "meaning": "код модели" + }, + { + "name": "model", + "meaning": "название модели" + }, + { + "name": "range", + "meaning": "дальность полета" + }, + { + "name": "speed", + "meaning": "скорость" + } + ], + "fill": "Демо-БД восстанавливается из seed-дампа; генератор добавляет учебные дни. PXF читает ее через `stg.airplanes_ext`.", + "status": "source", + "links": [ + { + "label": "Источник и генератор", + "url": "../../bookings/README.md" + }, + { + "label": "DDL входа PXF", + "url": "../../sql/stg/airplanes_ddl.sql" + } + ], + "parts": [] + }, + { + "id": "stg.airplanes", + "name": "stg.airplanes", + "layer": "stg", + "order": 7, + "what": "Справочник моделей самолетов; бортовых номеров отдельных самолетов здесь нет. Здесь бизнес-поля хранятся как TEXT.", + "grain": "Одна модель самолета. Строка относится к загрузке STG.", + "keys": "Бизнес-ключ: `airplane_code`. Для хранимой строки учитывайте также `_load_id`: снимки разных запусков сосуществуют.", + "fields": [ + { + "name": "airplane_code", + "meaning": "код модели" + }, + { + "name": "model", + "meaning": "название модели" + }, + { + "name": "range", + "meaning": "дальность полета" + }, + { + "name": "speed", + "meaning": "скорость" + }, + { + "name": "event_ts", + "meaning": "время снимка (`now()`)" + }, + { + "name": "_load_id", + "meaning": "идентификатор загрузки" + }, + { + "name": "_load_ts", + "meaning": "время записи в STG" + } + ], + "fill": "Полный снимок при каждом новом запуске STG; повтор в том же `_load_id` не добавляет строку повторно.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/stg/airplanes_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/stg/airplanes_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/stg/airplanes_dq.sql" + } + ], + "parts": [ + "stg.airplanes_ext" + ] + }, + { + "id": "ods.airplanes", + "name": "ods.airplanes", + "layer": "ods", + "order": 7, + "what": "Справочник моделей самолетов; бортовых номеров отдельных самолетов здесь нет.", + "grain": "Одна модель самолета.", + "keys": "`airplane_code` — логический ключ текущей строки.", + "fields": [ + { + "name": "airplane_code", + "meaning": "код модели" + }, + { + "name": "model", + "meaning": "название модели" + }, + { + "name": "range_km", + "meaning": "дальность полета" + }, + { + "name": "speed_kmh", + "meaning": "скорость" + } + ], + "fill": "Ожидается по ТЗ: Полный снимок из согласованного батча STG (`stg_batch_id`): TRUNCATE + INSERT, одна строка на бизнес-ключ.", + "status": "student", + "links": [ + { + "label": "DDL", + "url": "../../sql/ods/airplanes_ddl.sql" + }, + { + "label": "Загрузка · заглушка", + "url": "../../sql/ods/airplanes_load.sql" + }, + { + "label": "DQ · заглушка", + "url": "../../sql/ods/airplanes_dq.sql" + }, + { + "label": "Задание 1.1", + "url": "../assignment/analyst_spec.md#11-odsairplanes" + } + ], + "parts": [] + }, + { + "id": "bookings.seats", + "name": "bookings.seats", + "layer": "source", + "order": 8, + "what": "Схема салона модели самолета. Номер места повторяется у разных моделей.", + "grain": "Одно место в салоне модели.", + "keys": "`airplane_code` + `seat_no` — логический ключ строки источника.", + "fields": [ + { + "name": "airplane_code", + "meaning": "модель самолета" + }, + { + "name": "seat_no", + "meaning": "номер места" + }, + { + "name": "fare_conditions", + "meaning": "класс обслуживания места" + } + ], + "fill": "Демо-БД восстанавливается из seed-дампа; генератор добавляет учебные дни. PXF читает ее через `stg.seats_ext`.", + "status": "source", + "links": [ + { + "label": "Источник и генератор", + "url": "../../bookings/README.md" + }, + { + "label": "DDL входа PXF", + "url": "../../sql/stg/seats_ddl.sql" + } + ], + "parts": [] + }, + { + "id": "stg.seats", + "name": "stg.seats", + "layer": "stg", + "order": 8, + "what": "Схема салона модели самолета. Номер места повторяется у разных моделей. Здесь бизнес-поля хранятся как TEXT.", + "grain": "Одно место в салоне модели. Строка относится к загрузке STG.", + "keys": "Бизнес-ключ: `airplane_code` + `seat_no`. Для хранимой строки учитывайте также `_load_id`: снимки разных запусков сосуществуют.", + "fields": [ + { + "name": "airplane_code", + "meaning": "модель самолета" + }, + { + "name": "seat_no", + "meaning": "номер места" + }, + { + "name": "fare_conditions", + "meaning": "класс обслуживания места" + }, + { + "name": "event_ts", + "meaning": "время снимка (`now()`)" + }, + { + "name": "_load_id", + "meaning": "идентификатор загрузки" + }, + { + "name": "_load_ts", + "meaning": "время записи в STG" + } + ], + "fill": "Полный снимок при каждом новом запуске STG; повтор в том же `_load_id` не добавляет строку повторно.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/stg/seats_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/stg/seats_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/stg/seats_dq.sql" + } + ], + "parts": [ + "stg.seats_ext" + ] + }, + { + "id": "ods.seats", + "name": "ods.seats", + "layer": "ods", + "order": 8, + "what": "Схема салона модели самолета. Номер места повторяется у разных моделей.", + "grain": "Одно место в салоне модели.", + "keys": "`airplane_code` + `seat_no` — логический ключ текущей строки.", + "fields": [ + { + "name": "airplane_code", + "meaning": "модель самолета" + }, + { + "name": "seat_no", + "meaning": "номер места" + }, + { + "name": "fare_conditions", + "meaning": "класс обслуживания места" + } + ], + "fill": "Ожидается по ТЗ: Полный снимок из согласованного батча STG (`stg_batch_id`): TRUNCATE + INSERT, одна строка на бизнес-ключ.", + "status": "student", + "links": [ + { + "label": "DDL", + "url": "../../sql/ods/seats_ddl.sql" + }, + { + "label": "Загрузка · заглушка", + "url": "../../sql/ods/seats_load.sql" + }, + { + "label": "DQ · заглушка", + "url": "../../sql/ods/seats_dq.sql" + }, + { + "label": "Задание 1.2", + "url": "../assignment/analyst_spec.md#12-odsseats" + } + ], + "parts": [] + }, + { + "id": "dds.dim_calendar", + "name": "dds.dim_calendar", + "layer": "dds", + "order": 0, + "what": "Дает дату и календарные признаки рейса.", + "grain": "Одна календарная дата.", + "keys": "`date_actual` — дата; `calendar_sk` — ключ измерения.", + "fields": [ + { + "name": "calendar_sk", + "meaning": "ключ даты" + }, + { + "name": "date_actual", + "meaning": "дата" + }, + { + "name": "year_actual", + "meaning": "год" + }, + { + "name": "month_actual", + "meaning": "месяц" + }, + { + "name": "day_of_week", + "meaning": "день недели ISO" + }, + { + "name": "is_weekend", + "meaning": "суббота или воскресенье" + } + ], + "fill": "Генерация дат 2016–2030 только при пустой таблице. Календарь не читается из ODS.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/dds/dim_calendar_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/dds/dim_calendar_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/dds/dim_calendar_dq.sql" + } + ], + "parts": [] + }, + { + "id": "dds.dim_tariffs", + "name": "dds.dim_tariffs", + "layer": "dds", + "order": 1, + "what": "Выносит классы обслуживания из сегментов в измерение.", + "grain": "Один класс обслуживания.", + "keys": "`fare_conditions` — бизнес-ключ; `tariff_sk` — ключ измерения.", + "fields": [ + { + "name": "tariff_sk", + "meaning": "ключ класса" + }, + { + "name": "fare_conditions", + "meaning": "Economy, Business или Comfort" + } + ], + "fill": "DISTINCT непустых `ods.segments.fare_conditions`; вставляются только новые значения с новым SK.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/dds/dim_tariffs_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/dds/dim_tariffs_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/dds/dim_tariffs_dq.sql" + } + ], + "parts": [] + }, + { + "id": "dds.dim_passengers", + "name": "dds.dim_passengers", + "layer": "dds", + "order": 2, + "what": "Собирает пассажиров из билетов: отдельного справочника пассажиров в источнике нет.", + "grain": "Один пассажир, даже если у него много билетов.", + "keys": "`passenger_id` — бизнес-ключ; `passenger_sk` — ключ измерения.", + "fields": [ + { + "name": "passenger_sk", + "meaning": "ключ пассажира" + }, + { + "name": "passenger_id", + "meaning": "идентификатор из билетов" + }, + { + "name": "passenger_name", + "meaning": "актуальное имя" + } + ], + "fill": "По ТЗ: SCD1; выбрать актуальное имя из билетов, добавлять новых пассажиров и обновлять изменившиеся имена.", + "status": "student", + "links": [ + { + "label": "DDL", + "url": "../../sql/dds/dim_passengers_ddl.sql" + }, + { + "label": "Загрузка · заглушка", + "url": "../../sql/dds/dim_passengers_load.sql" + }, + { + "label": "DQ · заглушка", + "url": "../../sql/dds/dim_passengers_dq.sql" + }, + { + "label": "Задание", + "url": "../assignment/analyst_spec.md#22-ddsdim_passengers-scd1" + } + ], + "parts": [] + }, + { + "id": "dds.dim_routes", + "name": "dds.dim_routes", + "layer": "dds", + "order": 3, + "what": "Хранит версии маршрута для выбора состояния на дату рейса.", + "grain": "Одна версия маршрута на интервале `[valid_from, valid_to)`.", + "keys": "`route_bk` повторяется в истории; `route_sk` обозначает одну версию. `valid_to IS NULL` — открытая версия.", + "fields": [ + { + "name": "route_sk", + "meaning": "ключ версии" + }, + { + "name": "route_bk", + "meaning": "номер маршрута" + }, + { + "name": "airplane_code", + "meaning": "модель самолета этой версии" + }, + { + "name": "departure_airport", + "meaning": "код вылета" + }, + { + "name": "arrival_airport", + "meaning": "код прилета" + }, + { + "name": "total_seats", + "meaning": "число мест" + }, + { + "name": "valid_from", + "meaning": "начало версии" + }, + { + "name": "valid_to", + "meaning": "конец версии, не включается" + }, + { + "name": "hashdiff", + "meaning": "признак изменения атрибутов" + } + ], + "fill": "По ТЗ: SCD2 для атрибутов маршрута; города, модель и число мест обогащаются из измерений. Их обновление у открытых версий само по себе новую версию не создает.", + "status": "student", + "links": [ + { + "label": "DDL", + "url": "../../sql/dds/dim_routes_ddl.sql" + }, + { + "label": "Загрузка · заглушка", + "url": "../../sql/dds/dim_routes_load.sql" + }, + { + "label": "DQ · заглушка", + "url": "../../sql/dds/dim_routes_dq.sql" + }, + { + "label": "Задание", + "url": "../assignment/analyst_spec.md#23-ddsdim_routes-scd2" + } + ], + "parts": [] + }, + { + "id": "dds.fact_flight_sales", + "name": "dds.fact_flight_sales", + "layer": "dds", + "order": 4, + "what": "Связывает проданный сегмент с измерениями; служит основой витрин.", + "grain": "Один сегмент билета: `(ticket_no, flight_id)`. Один билет с пересадкой дает несколько строк.", + "keys": "`ticket_no` + `flight_id` — логическое зерно. `book_ref` повторяется у разных билетов и сегментов. Собственного `fact_sk` нет.", + "fields": [ + { + "name": "ticket_no", + "meaning": "билет" + }, + { + "name": "flight_id", + "meaning": "рейс" + }, + { + "name": "book_ref", + "meaning": "бронирование" + }, + { + "name": "book_date", + "meaning": "дата бронирования" + }, + { + "name": "price", + "meaning": "цена сегмента из `ods.segments.amount`" + }, + { + "name": "is_boarded", + "meaning": "есть ли посадочный талон" + }, + { + "name": "seat_no", + "meaning": "место из талона" + }, + { + "name": "calendar_sk", + "meaning": "дата вылета" + }, + { + "name": "departure_airport_sk", + "meaning": "аэропорт вылета" + }, + { + "name": "arrival_airport_sk", + "meaning": "аэропорт прилета" + }, + { + "name": "route_sk", + "meaning": "версия маршрута на дату вылета" + }, + { + "name": "airplane_sk", + "meaning": "модель через выбранную версию маршрута" + }, + { + "name": "passenger_sk", + "meaning": "пассажир" + }, + { + "name": "tariff_sk", + "meaning": "класс обслуживания" + } + ], + "fill": "INSERT новых пар `(ticket_no, flight_id)`; UPDATE меняет только `seat_no`, `price`, `is_boarded` и метаданные загрузки. Ключи измерений фиксируются при вставке. До выполнения заданий `passenger_sk`, `route_sk`, `airplane_sk` могут быть NULL. Повторный запуск их не заполнит: после реализации измерений нужен пересчет факта по ТЗ.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/dds/fact_flight_sales_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/dds/fact_flight_sales_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/dds/fact_flight_sales_dq.sql" + }, + { + "label": "Пересчет после реализации измерений", + "url": "../assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений" + } + ], + "parts": [] + }, + { + "id": "dds.dim_airports", + "name": "dds.dim_airports", + "layer": "dds", + "order": 6, + "what": "Один справочник для двух ролей аэропорта в факте: вылет и прилет.", + "grain": "Один аэропорт в текущем состоянии.", + "keys": "`airport_bk` — код из `ods.airports.airport_code`; `airport_sk` — ключ измерения.", + "fields": [ + { + "name": "airport_sk", + "meaning": "ключ аэропорта" + }, + { + "name": "airport_bk", + "meaning": "код аэропорта" + }, + { + "name": "city", + "meaning": "город" + }, + { + "name": "airport_name", + "meaning": "название" + }, + { + "name": "timezone", + "meaning": "часовой пояс" + } + ], + "fill": "SCD1 из `ods.airports`: UPDATE изменившихся атрибутов, INSERT новых аэропортов с новым SK.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/dds/dim_airports_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/dds/dim_airports_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/dds/dim_airports_dq.sql" + } + ], + "parts": [] + }, + { + "id": "dds.dim_airplanes", + "name": "dds.dim_airplanes", + "layer": "dds", + "order": 7, + "what": "Описывает модель самолета и вместимость ее салона.", + "grain": "Одна модель самолета.", + "keys": "`airplane_bk` — код модели; `airplane_sk` — ключ измерения.", + "fields": [ + { + "name": "airplane_sk", + "meaning": "ключ модели" + }, + { + "name": "airplane_bk", + "meaning": "код модели" + }, + { + "name": "model", + "meaning": "название" + }, + { + "name": "total_seats", + "meaning": "количество мест из `ods.seats`" + }, + { + "name": "range_km", + "meaning": "дальность, км" + }, + { + "name": "speed_kmh", + "meaning": "скорость, км/ч" + } + ], + "fill": "По ТЗ: SCD1 из `ods.airplanes` с подсчетом мест из `ods.seats` по коду модели. Сейчас загрузка и DQ — заглушки.", + "status": "student", + "links": [ + { + "label": "DDL", + "url": "../../sql/dds/dim_airplanes_ddl.sql" + }, + { + "label": "Загрузка · заглушка", + "url": "../../sql/dds/dim_airplanes_load.sql" + }, + { + "label": "DQ · заглушка", + "url": "../../sql/dds/dim_airplanes_dq.sql" + }, + { + "label": "Задание", + "url": "../assignment/analyst_spec.md#21-ddsdim_airplanes-scd1" + } + ], + "parts": [] + }, + { + "id": "dm.sales_report", + "name": "dm.sales_report", + "layer": "dm", + "order": 0, + "what": "Показывает выручку и долю посадок по направлениям и классам обслуживания.", + "grain": "Один день вылета × аэропорт вылета × аэропорт прилета × класс обслуживания.", + "keys": "`flight_date` + `departure_airport_sk` + `arrival_airport_sk` + `tariff_sk` — логический ключ агрегата.", + "fields": [ + { + "name": "flight_date", + "meaning": "дата вылета" + }, + { + "name": "departure_airport_sk", + "meaning": "аэропорт вылета" + }, + { + "name": "arrival_airport_sk", + "meaning": "аэропорт прилета" + }, + { + "name": "tariff_sk", + "meaning": "класс обслуживания" + }, + { + "name": "tickets_sold", + "meaning": "число сегментов, COUNT(*) факта" + }, + { + "name": "total_revenue", + "meaning": "сумма цен сегментов" + }, + { + "name": "passengers_boarded", + "meaning": "сегменты с посадочным талоном" + }, + { + "name": "boarding_rate", + "meaning": "доля сегментов с талоном" + } + ], + "fill": "По HWM факта выбираются затронутые даты; за эти даты агрегируются все факты, выполняются UPDATE и INSERT.", + "status": "ready", + "links": [ + { + "label": "DDL", + "url": "../../sql/dm/sales_report_ddl.sql" + }, + { + "label": "Загрузка", + "url": "../../sql/dm/sales_report_load.sql" + }, + { + "label": "DQ", + "url": "../../sql/dm/sales_report_dq.sql" + } + ], + "parts": [] + }, + { + "id": "dm.airport_traffic", + "name": "dm.airport_traffic", + "layer": "dm", + "order": 2, + "what": "Сводит вылеты и прилеты каждого аэропорта за день.", + "grain": "Один аэропорт за день.", + "keys": "`traffic_date` + `airport_sk` — логический ключ агрегата.", + "fields": [ + { + "name": "traffic_date", + "meaning": "дата из календаря вылета" + }, + { + "name": "airport_sk", + "meaning": "аэропорт в роли вылета или прилета" + }, + { + "name": "total_passengers", + "meaning": "посадки по обеим ролям" + }, + { + "name": "departures_revenue", + "meaning": "выручка вылетов" + }, + { + "name": "arrivals_revenue", + "meaning": "выручка прилетов" + } + ], + "fill": "По ТЗ: пересчитывать затронутые дни. Каждый факт участвует в двух ролях. Выручку вылетов и прилетов нельзя складывать: получится двойной счет. Дата обеих ролей берется из календаря факта (дата вылета).", + "status": "student", + "links": [ + { + "label": "DDL", + "url": "../../sql/dm/airport_traffic_ddl.sql" + }, + { + "label": "Загрузка · заглушка", + "url": "../../sql/dm/airport_traffic_load.sql" + }, + { + "label": "DQ · заглушка", + "url": "../../sql/dm/airport_traffic_dq.sql" + }, + { + "label": "Задание", + "url": "../assignment/analyst_spec.md#31-dmairport_traffic" + } + ], + "parts": [] + }, + { + "id": "dm.route_performance", + "name": "dm.route_performance", + "layer": "dm", + "order": 4, + "what": "Сравнивает маршруты по выручке, посадкам и заполняемости.", + "grain": "Один маршрут за всю историю, объединяя его версии.", + "keys": "`route_bk` — логический ключ агрегата.", + "fields": [ + { + "name": "route_bk", + "meaning": "маршрут независимо от версии" + }, + { + "name": "route_sk", + "meaning": "ключ текущей версии" + }, + { + "name": "total_flights", + "meaning": "уникальные рейсы" + }, + { + "name": "total_tickets", + "meaning": "проданные сегменты" + }, + { + "name": "avg_load_factor", + "meaning": "посадки относительно числа рейсов и мест текущей версии" + } + ], + "fill": "По ТЗ: полный пересчет; факты всех версий группируются по бизнес-ключу маршрута, описания берутся из открытой версии.", + "status": "student", + "links": [ + { + "label": "DDL", + "url": "../../sql/dm/route_performance_ddl.sql" + }, + { + "label": "Загрузка · заглушка", + "url": "../../sql/dm/route_performance_load.sql" + }, + { + "label": "DQ · заглушка", + "url": "../../sql/dm/route_performance_dq.sql" + }, + { + "label": "Задание", + "url": "../assignment/analyst_spec.md#32-dmroute_performance" + } + ], + "parts": [] + }, + { + "id": "dm.monthly_overview", + "name": "dm.monthly_overview", + "layer": "dm", + "order": 6, + "what": "Показывает помесячные показатели по моделям самолетов.", + "grain": "Одна модель самолета за месяц.", + "keys": "`year_actual` + `month_actual` + `airplane_sk` — логический ключ агрегата.", + "fields": [ + { + "name": "year_actual", + "meaning": "год" + }, + { + "name": "month_actual", + "meaning": "месяц" + }, + { + "name": "airplane_sk", + "meaning": "модель" + }, + { + "name": "total_flights", + "meaning": "число уникальных рейсов" + }, + { + "name": "avg_load_factor", + "meaning": "средняя заполняемость по рейсам" + } + ], + "fill": "По ТЗ: пересчитывать затронутые месяцы; сначала считать показатели рейса, затем месяца.", + "status": "student", + "links": [ + { + "label": "DDL", + "url": "../../sql/dm/monthly_overview_ddl.sql" + }, + { + "label": "Загрузка · заглушка", + "url": "../../sql/dm/monthly_overview_load.sql" + }, + { + "label": "DQ · заглушка", + "url": "../../sql/dm/monthly_overview_dq.sql" + }, + { + "label": "Задание", + "url": "../assignment/analyst_spec.md#33-dmmonthly_overview" + } + ], + "parts": [] + }, + { + "id": "dm.passenger_loyalty", + "name": "dm.passenger_loyalty", + "layer": "dm", + "order": 8, + "what": "Собирает историю перелетов и трат пассажира.", + "grain": "Один пассажир за всю историю.", + "keys": "`passenger_sk` — логический ключ агрегата.", + "fields": [ + { + "name": "passenger_sk", + "meaning": "ключ пассажира" + }, + { + "name": "passenger_bk", + "meaning": "исходный passenger_id" + }, + { + "name": "total_bookings", + "meaning": "уникальные бронирования" + }, + { + "name": "total_flights", + "meaning": "число сегментов пассажира" + }, + { + "name": "total_spent", + "meaning": "сумма цен" + }, + { + "name": "favorite_fare_conditions", + "meaning": "самый частый класс обслуживания" + } + ], + "fill": "По ТЗ: пересчитывать всю историю пассажиров, чьи факты изменились. Строки без `passenger_sk` исключаются.", + "status": "student", + "links": [ + { + "label": "DDL", + "url": "../../sql/dm/passenger_loyalty_ddl.sql" + }, + { + "label": "Загрузка · заглушка", + "url": "../../sql/dm/passenger_loyalty_load.sql" + }, + { + "label": "DQ · заглушка", + "url": "../../sql/dm/passenger_loyalty_dq.sql" + }, + { + "label": "Задание", + "url": "../assignment/analyst_spec.md#34-dmpassenger_loyalty" + } + ], + "parts": [] + } + ], + "edges": [ + { + "id": "flow-bookings.bookings-stg.bookings-0", + "from": "bookings.bookings", + "to": "stg.bookings", + "label": "Чтение через PXF", + "join": "`stg.bookings_ext` читает `bookings.bookings` без локальной копии. Загрузчик переносит выбранные строки в `stg.bookings`. Добавление строк по `event_ts` предыдущих загрузок. Опорное время — `book_date` бронирования. Повтор защищен ключом строки внутри `_load_id`.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "bookings.bookings", + "columns": [ + "book_ref" + ] + }, + { + "node": "stg.bookings", + "columns": [ + "book_ref" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/stg/bookings_load.sql" + } + ] + }, + { + "id": "flow-stg.bookings-ods.bookings-1", + "from": "stg.bookings", + "to": "ods.bookings", + "label": "Типизация и загрузка ODS", + "join": "Дельта из STG по `_load_ts`, приведение типов, выбор последней строки по бизнес-ключу; UPDATE изменившихся и INSERT новых. Время и идентификатор загрузки сохраняются из STG.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "stg.bookings", + "columns": [ + "book_ref" + ] + }, + { + "node": "ods.bookings", + "columns": [ + "book_ref" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/ods/bookings_load.sql" + } + ] + }, + { + "id": "flow-bookings.tickets-stg.tickets-2", + "from": "bookings.tickets", + "to": "stg.tickets", + "label": "Чтение через PXF", + "join": "`stg.tickets_ext` читает `bookings.tickets` без локальной копии. Загрузчик переносит выбранные строки в `stg.tickets`. Добавление строк по `event_ts` предыдущих загрузок. Опорное время — `book_date` бронирования. Уже сохраненный `ticket_no` повторно не вставляется.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "bookings.tickets", + "columns": [ + "ticket_no" + ] + }, + { + "node": "stg.tickets", + "columns": [ + "ticket_no" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/stg/tickets_load.sql" + } + ] + }, + { + "id": "flow-stg.tickets-ods.tickets-3", + "from": "stg.tickets", + "to": "ods.tickets", + "label": "Типизация и загрузка ODS", + "join": "Дельта из STG по `_load_ts`, приведение типов, выбор последней строки по бизнес-ключу; UPDATE изменившихся и INSERT новых. Время и идентификатор загрузки сохраняются из STG. `outbound` становится `is_outbound` (BOOLEAN).", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "stg.tickets", + "columns": [ + "ticket_no" + ] + }, + { + "node": "ods.tickets", + "columns": [ + "ticket_no" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/ods/tickets_load.sql" + } + ] + }, + { + "id": "flow-bookings.segments-stg.segments-4", + "from": "bookings.segments", + "to": "stg.segments", + "label": "Чтение через PXF", + "join": "`stg.segments_ext` читает `bookings.segments` без локальной копии. Загрузчик переносит выбранные строки в `stg.segments`. Добавление строк по `event_ts` предыдущих загрузок. Опорное время — `book_date` бронирования. Повтор защищен ключом строки внутри `_load_id`.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "bookings.segments", + "columns": [ + "ticket_no", + "flight_id" + ] + }, + { + "node": "stg.segments", + "columns": [ + "ticket_no", + "flight_id" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/stg/segments_load.sql" + } + ] + }, + { + "id": "flow-stg.segments-ods.segments-5", + "from": "stg.segments", + "to": "ods.segments", + "label": "Типизация и загрузка ODS", + "join": "Дельта из STG по `_load_ts`, приведение типов, выбор последней строки по бизнес-ключу; UPDATE изменившихся и INSERT новых. Время и идентификатор загрузки сохраняются из STG. `stg.segments.price` становится `amount` (NUMERIC).", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "stg.segments", + "columns": [ + "ticket_no", + "flight_id" + ] + }, + { + "node": "ods.segments", + "columns": [ + "ticket_no", + "flight_id" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/ods/segments_load.sql" + } + ] + }, + { + "id": "flow-bookings.flights-stg.flights-6", + "from": "bookings.flights", + "to": "stg.flights", + "label": "Чтение через PXF", + "join": "`stg.flights_ext` читает `bookings.flights` без локальной копии. Загрузчик переносит выбранные строки в `stg.flights`. Добавление строк по `event_ts` предыдущих загрузок. Опорное время — `scheduled_departure`. Повтор защищен ключом строки внутри `_load_id`.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "bookings.flights", + "columns": [ + "flight_id" + ] + }, + { + "node": "stg.flights", + "columns": [ + "flight_id" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/stg/flights_load.sql" + } + ] + }, + { + "id": "flow-stg.flights-ods.flights-7", + "from": "stg.flights", + "to": "ods.flights", + "label": "Типизация и загрузка ODS", + "join": "Дельта из STG по `_load_ts`, приведение типов, выбор последней строки по бизнес-ключу; UPDATE изменившихся и INSERT новых. Время и идентификатор загрузки сохраняются из STG. Дополнительно берутся рейсы из истории STG, на которые ссылаются новые `stg.segments`.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "stg.flights", + "columns": [ + "flight_id" + ] + }, + { + "node": "ods.flights", + "columns": [ + "flight_id" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/ods/flights_load.sql" + } + ] + }, + { + "id": "flow-bookings.boarding_passes-stg.boarding_passes-8", + "from": "bookings.boarding_passes", + "to": "stg.boarding_passes", + "label": "Чтение через PXF", + "join": "`stg.boarding_passes_ext` читает `bookings.boarding_passes` без локальной копии. Загрузчик переносит выбранные строки в `stg.boarding_passes`. Добавление строк по `event_ts` предыдущих загрузок. Опорное время — `book_date` бронирования. Повтор защищен ключом строки внутри `_load_id`.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "bookings.boarding_passes", + "columns": [ + "ticket_no", + "flight_id" + ] + }, + { + "node": "stg.boarding_passes", + "columns": [ + "ticket_no", + "flight_id" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/stg/boarding_passes_load.sql" + } + ] + }, + { + "id": "flow-stg.boarding_passes-ods.boarding_passes-9", + "from": "stg.boarding_passes", + "to": "ods.boarding_passes", + "label": "Типизация и загрузка ODS", + "join": "Дельта из STG по `_load_ts`, приведение типов, выбор последней строки по бизнес-ключу; UPDATE изменившихся и INSERT новых. Время и идентификатор загрузки сохраняются из STG.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "stg.boarding_passes", + "columns": [ + "ticket_no", + "flight_id" + ] + }, + { + "node": "ods.boarding_passes", + "columns": [ + "ticket_no", + "flight_id" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/ods/boarding_passes_load.sql" + } + ] + }, + { + "id": "flow-bookings.routes-stg.routes-10", + "from": "bookings.routes", + "to": "stg.routes", + "label": "Чтение через PXF", + "join": "`stg.routes_ext` читает `bookings.routes` без локальной копии. Загрузчик переносит выбранные строки в `stg.routes`. Полный снимок при каждом новом запуске STG; повтор в том же `_load_id` не добавляет строку повторно.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "bookings.routes", + "columns": [ + "route_no", + "validity" + ] + }, + { + "node": "stg.routes", + "columns": [ + "route_no", + "validity" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/stg/routes_load.sql" + } + ] + }, + { + "id": "flow-stg.routes-ods.routes-11", + "from": "stg.routes", + "to": "ods.routes", + "label": "Типизация и загрузка ODS", + "join": "Полный снимок из согласованного батча STG (`stg_batch_id`): TRUNCATE + INSERT, одна строка на бизнес-ключ. `scheduled_time` становится `departure_time` (TIME), `days_of_week` — INTEGER[], `duration` — INTERVAL.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "stg.routes", + "columns": [ + "route_no", + "validity" + ] + }, + { + "node": "ods.routes", + "columns": [ + "route_no", + "validity" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/ods/routes_load.sql" + } + ] + }, + { + "id": "flow-bookings.airports_data-stg.airports-12", + "from": "bookings.airports_data", + "to": "stg.airports", + "label": "Чтение через PXF", + "join": "`stg.airports_ext` читает `bookings.airports_data` без локальной копии. Загрузчик переносит выбранные строки в `stg.airports`. Полный снимок при каждом новом запуске STG; повтор в том же `_load_id` не добавляет строку повторно.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "bookings.airports_data", + "columns": [ + "airport_code" + ] + }, + { + "node": "stg.airports", + "columns": [ + "airport_code" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/stg/airports_load.sql" + } + ] + }, + { + "id": "flow-stg.airports-ods.airports-13", + "from": "stg.airports", + "to": "ods.airports", + "label": "Типизация и загрузка ODS", + "join": "Полный снимок из согласованного батча STG (`stg_batch_id`): TRUNCATE + INSERT, одна строка на бизнес-ключ. Из JSON извлекаются русские названия аэропорта, города и страны.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "stg.airports", + "columns": [ + "airport_code" + ] + }, + { + "node": "ods.airports", + "columns": [ + "airport_code" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/ods/airports_load.sql" + } + ] + }, + { + "id": "flow-bookings.airplanes_data-stg.airplanes-14", + "from": "bookings.airplanes_data", + "to": "stg.airplanes", + "label": "Чтение через PXF", + "join": "`stg.airplanes_ext` читает `bookings.airplanes_data` без локальной копии. Загрузчик переносит выбранные строки в `stg.airplanes`. Полный снимок при каждом новом запуске STG; повтор в том же `_load_id` не добавляет строку повторно.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "bookings.airplanes_data", + "columns": [ + "airplane_code" + ] + }, + { + "node": "stg.airplanes", + "columns": [ + "airplane_code" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/stg/airplanes_load.sql" + } + ] + }, + { + "id": "flow-stg.airplanes-ods.airplanes-15", + "from": "stg.airplanes", + "to": "ods.airplanes", + "label": "Типизация и загрузка ODS", + "join": "Ожидается по ТЗ: Полный снимок из согласованного батча STG (`stg_batch_id`): TRUNCATE + INSERT, одна строка на бизнес-ключ.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "stg.airplanes", + "columns": [ + "airplane_code" + ] + }, + { + "node": "ods.airplanes", + "columns": [ + "airplane_code" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#11-odsairplanes" + } + ] + }, + { + "id": "flow-bookings.seats-stg.seats-16", + "from": "bookings.seats", + "to": "stg.seats", + "label": "Чтение через PXF", + "join": "`stg.seats_ext` читает `bookings.seats` без локальной копии. Загрузчик переносит выбранные строки в `stg.seats`. Полный снимок при каждом новом запуске STG; повтор в том же `_load_id` не добавляет строку повторно.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "bookings.seats", + "columns": [ + "airplane_code", + "seat_no" + ] + }, + { + "node": "stg.seats", + "columns": [ + "airplane_code", + "seat_no" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/stg/seats_load.sql" + } + ] + }, + { + "id": "flow-stg.seats-ods.seats-17", + "from": "stg.seats", + "to": "ods.seats", + "label": "Типизация и загрузка ODS", + "join": "Ожидается по ТЗ: Полный снимок из согласованного батча STG (`stg_batch_id`): TRUNCATE + INSERT, одна строка на бизнес-ключ.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "stg.seats", + "columns": [ + "airplane_code", + "seat_no" + ] + }, + { + "node": "ods.seats", + "columns": [ + "airplane_code", + "seat_no" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#12-odsseats" + } + ] + }, + { + "id": "flow-bookings.bookings-stg.tickets-18", + "from": "bookings.bookings", + "to": "stg.tickets", + "label": "Дата отбора", + "join": "`book_date` из `stg.bookings_ext` определяет окно. INNER JOIN по `book_ref` с билетом. Без бронирования строка не проходит соединение.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "bookings.bookings", + "columns": [ + "book_ref", + "book_date" + ] + }, + { + "node": "bookings.tickets", + "columns": [ + "ticket_no", + "book_ref" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/stg/tickets_load.sql" + } + ] + }, + { + "id": "flow-bookings.bookings-stg.segments-19", + "from": "bookings.bookings", + "to": "stg.segments", + "label": "Дата отбора", + "join": "`book_date` из `stg.bookings_ext` определяет окно. Сегмент/талон соединяется с `stg.tickets_ext` по `ticket_no`, затем с `stg.bookings_ext` по `book_ref`. Без бронирования строка не проходит соединение.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "bookings.bookings", + "columns": [ + "book_ref", + "book_date" + ] + }, + { + "node": "bookings.tickets", + "columns": [ + "ticket_no", + "book_ref" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/stg/segments_load.sql" + } + ] + }, + { + "id": "flow-bookings.bookings-stg.boarding_passes-20", + "from": "bookings.bookings", + "to": "stg.boarding_passes", + "label": "Дата отбора", + "join": "`book_date` из `stg.bookings_ext` определяет окно. Сегмент/талон соединяется с `stg.tickets_ext` по `ticket_no`, затем с `stg.bookings_ext` по `book_ref`. Без бронирования строка не проходит соединение.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "bookings.bookings", + "columns": [ + "book_ref", + "book_date" + ] + }, + { + "node": "bookings.tickets", + "columns": [ + "ticket_no", + "book_ref" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/stg/boarding_passes_load.sql" + } + ] + }, + { + "id": "flow-bookings.tickets-stg.segments-21", + "from": "bookings.tickets", + "to": "stg.segments", + "label": "Билет для отбора", + "join": "INNER JOIN по `ticket_no` дает `book_ref`, через него находится дата бронирования. Без билета строка не попадет в STG.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "bookings.tickets", + "columns": [ + "ticket_no", + "book_ref" + ] + }, + { + "node": "stg.segments", + "columns": [ + "ticket_no" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/stg/segments_load.sql" + } + ] + }, + { + "id": "flow-bookings.tickets-stg.boarding_passes-22", + "from": "bookings.tickets", + "to": "stg.boarding_passes", + "label": "Билет для отбора", + "join": "INNER JOIN по `ticket_no` дает `book_ref`, через него находится дата бронирования. Без билета строка не попадет в STG.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "bookings.tickets", + "columns": [ + "ticket_no", + "book_ref" + ] + }, + { + "node": "stg.boarding_passes", + "columns": [ + "ticket_no" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/stg/boarding_passes_load.sql" + } + ] + }, + { + "id": "flow-stg.segments-ods.flights-23", + "from": "stg.segments", + "to": "ods.flights", + "label": "Рейсы новых сегментов", + "join": "`flight_id` новых сегментов определяет, какие рейсы дополнительно взять из истории `stg.flights`. Если рейса там нет, эта ветка его не создаст; связь проверяет DQ сегментов.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "stg.segments", + "columns": [ + "flight_id", + "_load_ts" + ] + }, + { + "node": "stg.flights", + "columns": [ + "flight_id" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/ods/flights_load.sql" + } + ] + }, + { + "id": "flow-ods.airports-dds.dim_airports-24", + "from": "ods.airports", + "to": "dds.dim_airports", + "label": "Код аэропорта → SK", + "join": "`airport_code = airport_bk`: атрибуты существующего аэропорта обновляются, для нового кода создается строка с SK.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "ods.airports", + "columns": [ + "airport_code" + ] + }, + { + "node": "dds.dim_airports", + "columns": [ + "airport_bk", + "airport_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/dim_airports_load.sql" + } + ] + }, + { + "id": "flow-ods.segments-dds.dim_tariffs-25", + "from": "ods.segments", + "to": "dds.dim_tariffs", + "label": "Уникальные классы", + "join": "Из `fare_conditions` выбираются уникальные непустые значения. Новому классу назначается `tariff_sk`; отсутствие сегментов не удаляет старые классы.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "ods.segments", + "columns": [ + "fare_conditions" + ] + }, + { + "node": "dds.dim_tariffs", + "columns": [ + "fare_conditions", + "tariff_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/dim_tariffs_load.sql" + } + ] + }, + { + "id": "flow-ods.airplanes-dds.dim_airplanes-26", + "from": "ods.airplanes", + "to": "dds.dim_airplanes", + "label": "Описание модели", + "join": "По ТЗ: `airplane_code` становится `airplane_bk`; характеристики модели получают свой SK.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "ods.airplanes", + "columns": [ + "airplane_code" + ] + }, + { + "node": "dds.dim_airplanes", + "columns": [ + "airplane_bk", + "airplane_sk" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#21-ddsdim_airplanes-scd1" + } + ] + }, + { + "id": "flow-ods.seats-dds.dim_airplanes-27", + "from": "ods.seats", + "to": "dds.dim_airplanes", + "label": "Вместимость модели", + "join": "По ТЗ: места считаются по `airplane_code` и добавляют `total_seats`. LEFT JOIN сохраняет модель без совпавших мест. Точное значение для отсутствующих мест ТЗ не оговаривает; нулевую вместимость нельзя трактовать как известную.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "ods.seats", + "columns": [ + "airplane_code", + "seat_no" + ] + }, + { + "node": "dds.dim_airplanes", + "columns": [ + "airplane_bk", + "total_seats" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#21-ddsdim_airplanes-scd1" + } + ] + }, + { + "id": "flow-ods.tickets-dds.dim_passengers-28", + "from": "ods.tickets", + "to": "dds.dim_passengers", + "label": "Пассажиры из билетов", + "join": "По ТЗ: несколько билетов одного `passenger_id` дают одного пассажира с актуальным именем. Отдельного JOIN к справочнику пассажиров источника нет.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "ods.tickets", + "columns": [ + "passenger_id", + "passenger_name" + ] + }, + { + "node": "dds.dim_passengers", + "columns": [ + "passenger_id", + "passenger_sk" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#22-ddsdim_passengers-scd1" + } + ] + }, + { + "id": "flow-ods.routes-dds.dim_routes-29", + "from": "ods.routes", + "to": "dds.dim_routes", + "label": "История маршрута", + "join": "По ТЗ: `route_no` становится `route_bk`; изменения атрибутов создают новую версию с `route_sk` и интервалом действия. Исчезновение маршрута закрывает открытую версию.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "ods.routes", + "columns": [ + "route_no" + ] + }, + { + "node": "dds.dim_routes", + "columns": [ + "route_bk", + "route_sk", + "valid_from", + "valid_to" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#23-ddsdim_routes-scd2" + } + ] + }, + { + "id": "flow-dds.dim_airports-dds.dim_routes-30", + "from": "dds.dim_airports", + "to": "dds.dim_routes", + "label": "Города вылета и прилета", + "join": "По ТЗ: коды `departure_airport` и `arrival_airport` сопоставляются с `airport_bk`; добавляются два города. Тип JOIN при пропуске ТЗ не задает, но города в DDL обязательны: пропуск нельзя молча записать как NULL.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "dds.dim_airports", + "columns": [ + "airport_bk", + "city" + ] + }, + { + "node": "dds.dim_routes", + "columns": [ + "departure_airport", + "arrival_airport", + "departure_city", + "arrival_city" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#23-ddsdim_routes-scd2" + } + ] + }, + { + "id": "flow-dds.dim_airplanes-dds.dim_routes-31", + "from": "dds.dim_airplanes", + "to": "dds.dim_routes", + "label": "Модель и места", + "join": "По ТЗ: `airplane_code = airplane_bk` дает `airplane_model` и `total_seats`. Тип JOIN при пропуске не определен; оба поля в DDL обязательны. Это требуемое обогащение, загрузчик пока пуст.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "dds.dim_airplanes", + "columns": [ + "airplane_bk", + "model", + "total_seats" + ] + }, + { + "node": "dds.dim_routes", + "columns": [ + "airplane_code", + "airplane_model", + "total_seats" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#23-ddsdim_routes-scd2" + } + ] + }, + { + "id": "fact-0", + "from": "ods.segments", + "to": "dds.fact_flight_sales", + "label": "Зерно и цена", + "join": "Основной поток: пара `(ticket_no, flight_id)` и `amount → price`. Нет сегмента — нет новой строки факта.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "ods.segments", + "columns": [ + "ticket_no", + "flight_id", + "amount" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "ticket_no", + "flight_id", + "price" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "fact-1", + "from": "ods.tickets", + "to": "dds.fact_flight_sales", + "label": "Билет и пассажир", + "join": "INNER JOIN `tkt.ticket_no = seg.ticket_no` дает `book_ref` и `passenger_id` для дальнейших соединений. Без билета сегмент не попадет в новый факт.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "ods.tickets", + "columns": [ + "ticket_no", + "book_ref", + "passenger_id" + ] + }, + { + "node": "ods.segments", + "columns": [ + "ticket_no" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "fact-2", + "from": "ods.bookings", + "to": "dds.fact_flight_sales", + "label": "Дата бронирования", + "join": "INNER JOIN `bkg.book_ref = tkt.book_ref` дает `book_date::DATE`. Без бронирования сегмент не попадет в новый факт; `total_amount` здесь не используется.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "ods.bookings", + "columns": [ + "book_ref", + "book_date" + ] + }, + { + "node": "ods.tickets", + "columns": [ + "book_ref" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "book_date" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "fact-3", + "from": "ods.flights", + "to": "dds.fact_flight_sales", + "label": "Рейс, дата и маршрут", + "join": "INNER JOIN `flt.flight_id = seg.flight_id` дает `scheduled_departure` и `route_no` для lookup. Без рейса сегмент не попадет в новый факт.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "ods.flights", + "columns": [ + "flight_id", + "scheduled_departure", + "route_no" + ] + }, + { + "node": "ods.segments", + "columns": [ + "flight_id" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "fact-4", + "from": "ods.routes", + "to": "dds.fact_flight_sales", + "label": "Коды двух аэропортов", + "join": "Из ODS выбирается одна строка на `route_no` с наибольшим `validity` (поле TEXT). LEFT JOIN по `flt.route_no` дает коды вылета и прилета. Это отдельный путь от SCD2: дата рейса здесь не участвует. Если маршрута нет, оба airport_sk останутся NULL; DQ считает это аномалией, а не учебной заглушкой.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "ods.routes", + "columns": [ + "route_no", + "validity", + "departure_airport", + "arrival_airport" + ] + }, + { + "node": "ods.flights", + "columns": [ + "route_no" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "departure_airport_sk", + "arrival_airport_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "fact-5", + "from": "dds.dim_routes", + "to": "dds.fact_flight_sales", + "label": "Версия на дату рейса", + "join": "LEFT JOIN `rte.route_bk = flt.route_no` и `scheduled_departure::DATE >= valid_from`, а конец — `valid_to IS NULL` или дата `< valid_to`. Дает `route_sk` и `airplane_code` для поиска модели. Нет подходящей версии — route_sk и airplane_sk NULL, строка факта сохраняется. На main это ожидается до выполнения задания.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "dds.dim_routes", + "columns": [ + "route_bk", + "route_sk", + "airplane_code", + "valid_from", + "valid_to" + ] + }, + { + "node": "ods.flights", + "columns": [ + "route_no", + "scheduled_departure" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "route_sk", + "airplane_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "fact-6", + "from": "dds.dim_calendar", + "to": "dds.fact_flight_sales", + "label": "Дата вылета", + "join": "LEFT JOIN `date_actual = scheduled_departure::DATE` дает `calendar_sk`. Нет даты в календаре — SK будет NULL, строка факта сохраняется. Это не дата бронирования.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "dds.dim_calendar", + "columns": [ + "date_actual", + "calendar_sk" + ] + }, + { + "node": "ods.flights", + "columns": [ + "scheduled_departure" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "calendar_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "fact-7", + "from": "dds.dim_airports", + "to": "dds.fact_flight_sales", + "label": "Аэропорт вылета", + "join": "LEFT JOIN `dep.airport_bk = ods_rte.departure_airport` дает `departure_airport_sk`. Без аэропорта SK будет NULL, факт сохраняется. Код берется через ods.routes; пустой dim_routes этому не мешает.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "dds.dim_airports", + "columns": [ + "airport_bk", + "airport_sk" + ] + }, + { + "node": "ods.routes", + "columns": [ + "departure_airport" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "departure_airport_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "fact-8", + "from": "dds.dim_airports", + "to": "dds.fact_flight_sales", + "label": "Аэропорт прилета", + "join": "LEFT JOIN `arr.airport_bk = ods_rte.arrival_airport` дает `arrival_airport_sk`. Без аэропорта SK будет NULL, факт сохраняется. Это вторая роль того же измерения.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "dds.dim_airports", + "columns": [ + "airport_bk", + "airport_sk" + ] + }, + { + "node": "ods.routes", + "columns": [ + "arrival_airport" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "arrival_airport_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "fact-9", + "from": "dds.dim_airplanes", + "to": "dds.fact_flight_sales", + "label": "Модель выбранной версии", + "join": "LEFT JOIN `ap.airplane_bk = rte.airplane_code` дает `airplane_sk`. Код берется из выбранной по дате версии dim_routes. Без версии или модели SK будет NULL; факт сохраняется.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "dds.dim_airplanes", + "columns": [ + "airplane_bk", + "airplane_sk" + ] + }, + { + "node": "dds.dim_routes", + "columns": [ + "airplane_code" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "airplane_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "fact-10", + "from": "dds.dim_tariffs", + "to": "dds.fact_flight_sales", + "label": "Класс обслуживания", + "join": "LEFT JOIN `tar.fare_conditions = seg.fare_conditions` дает `tariff_sk`. Нет совпадения — факт сохраняется с NULL; последующая DQ факта считает такой NULL ошибкой.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "dds.dim_tariffs", + "columns": [ + "fare_conditions", + "tariff_sk" + ] + }, + { + "node": "ods.segments", + "columns": [ + "fare_conditions" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "tariff_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "fact-11", + "from": "dds.dim_passengers", + "to": "dds.fact_flight_sales", + "label": "Пассажир", + "join": "LEFT JOIN `pax.passenger_id = tkt.passenger_id` дает `passenger_sk`. Без пассажира факт сохраняется с NULL; на main это ожидается до выполнения задания.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "dds.dim_passengers", + "columns": [ + "passenger_id", + "passenger_sk" + ] + }, + { + "node": "ods.tickets", + "columns": [ + "passenger_id" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "passenger_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "fact-12", + "from": "ods.boarding_passes", + "to": "dds.fact_flight_sales", + "label": "Посадка и место", + "join": "LEFT JOIN по ОБОИМ полям `ticket_no` и `flight_id` дает `seat_no`; наличие талона означает `is_boarded = true`. Без талона `seat_no = NULL`, `is_boarded = false`, сегмент остается в факте.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "ods.boarding_passes", + "columns": [ + "ticket_no", + "flight_id", + "seat_no" + ] + }, + { + "node": "ods.segments", + "columns": [ + "ticket_no", + "flight_id" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "seat_no", + "is_boarded" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "flow-dds.fact_flight_sales-dm.sales_report-45", + "from": "dds.fact_flight_sales", + "to": "dm.sales_report", + "label": "Агрегация сегментов", + "join": "Один день вылета × аэропорт вылета × аэропорт прилета × класс обслуживания. По HWM факта выбираются затронутые даты; за эти даты агрегируются все факты, выполняются UPDATE и INSERT. INNER JOIN к календарю, двум ролям аэропортов и тарифу: факты без этих совпадений не входят в агрегат. `tickets_sold = COUNT(*)`, `total_revenue = SUM(price)`.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "dds.fact_flight_sales", + "columns": [ + "price", + "is_boarded" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dm/sales_report_load.sql" + } + ] + }, + { + "id": "flow-dds.dim_calendar-dm.sales_report-46", + "from": "dds.dim_calendar", + "to": "dm.sales_report", + "label": "Измерение для витрины", + "join": "Дает дату и календарные признаки по calendar_sk. INNER JOIN: без совпадения факт исключается из агрегата.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "dds.dim_calendar", + "columns": [ + "calendar_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "calendar_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dm/sales_report_load.sql" + } + ] + }, + { + "id": "flow-dds.dim_airports-dm.sales_report-47", + "from": "dds.dim_airports", + "to": "dm.sales_report", + "label": "Измерение для витрины", + "join": "Дает коды и города для departure_airport_sk и arrival_airport_sk; один справочник используется дважды. INNER JOIN: без совпадения факт исключается из агрегата.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "dds.dim_airports", + "columns": [ + "airport_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "departure_airport_sk", + "arrival_airport_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dm/sales_report_load.sql" + } + ] + }, + { + "id": "flow-dds.dim_tariffs-dm.sales_report-48", + "from": "dds.dim_tariffs", + "to": "dm.sales_report", + "label": "Измерение для витрины", + "join": "Дает класс обслуживания по tariff_sk. INNER JOIN: без совпадения факт исключается из агрегата.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "dds.dim_tariffs", + "columns": [ + "tariff_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "tariff_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dm/sales_report_load.sql" + } + ] + }, + { + "id": "flow-dds.fact_flight_sales-dm.airport_traffic-49", + "from": "dds.fact_flight_sales", + "to": "dm.airport_traffic", + "label": "Агрегация сегментов", + "join": "Один аэропорт за день. По ТЗ: пересчитывать затронутые дни. Каждый факт участвует в двух ролях. Выручку вылетов и прилетов нельзя складывать: получится двойной счет. Дата обеих ролей берется из календаря факта (дата вылета). Без фактов нет входных строк для агрегата.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "dds.fact_flight_sales", + "columns": [ + "price", + "is_boarded" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#31-dmairport_traffic" + } + ] + }, + { + "id": "flow-dds.dim_calendar-dm.airport_traffic-50", + "from": "dds.dim_calendar", + "to": "dm.airport_traffic", + "label": "Измерение для витрины", + "join": "Дает дату и календарные признаки по calendar_sk. Ожидаемая связь по ТЗ; загрузка пока заглушка. Тип JOIN и общее поведение при пропуске измерения ТЗ не задает.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "dds.dim_calendar", + "columns": [ + "calendar_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "calendar_sk" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#31-dmairport_traffic" + } + ] + }, + { + "id": "flow-dds.dim_airports-dm.airport_traffic-51", + "from": "dds.dim_airports", + "to": "dm.airport_traffic", + "label": "Измерение для витрины", + "join": "Дает коды и города для departure_airport_sk и arrival_airport_sk; один справочник используется дважды. Ожидаемая связь по ТЗ; загрузка пока заглушка. Тип JOIN и общее поведение при пропуске измерения ТЗ не задает.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "dds.dim_airports", + "columns": [ + "airport_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "departure_airport_sk", + "arrival_airport_sk" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#31-dmairport_traffic" + } + ] + }, + { + "id": "flow-dds.fact_flight_sales-dm.route_performance-52", + "from": "dds.fact_flight_sales", + "to": "dm.route_performance", + "label": "Агрегация сегментов", + "join": "Один маршрут за всю историю, объединяя его версии. По ТЗ: полный пересчет; факты всех версий группируются по бизнес-ключу маршрута, описания берутся из открытой версии. Без фактов нет входных строк для агрегата.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "dds.fact_flight_sales", + "columns": [ + "price", + "is_boarded" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#32-dmroute_performance" + } + ] + }, + { + "id": "flow-dds.dim_routes-dm.route_performance-53", + "from": "dds.dim_routes", + "to": "dm.route_performance", + "label": "Измерение для витрины", + "join": "Дает route_bk по route_sk для объединения версий маршрута. Ожидаемая связь по ТЗ; загрузка пока заглушка. Тип JOIN и общее поведение при пропуске измерения ТЗ не задает.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "dds.dim_routes", + "columns": [ + "route_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "route_sk" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#32-dmroute_performance" + } + ] + }, + { + "id": "flow-dds.dim_calendar-dm.route_performance-54", + "from": "dds.dim_calendar", + "to": "dm.route_performance", + "label": "Измерение для витрины", + "join": "Дает дату и календарные признаки по calendar_sk. Ожидаемая связь по ТЗ; загрузка пока заглушка. Тип JOIN и общее поведение при пропуске измерения ТЗ не задает.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "dds.dim_calendar", + "columns": [ + "calendar_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "calendar_sk" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#32-dmroute_performance" + } + ] + }, + { + "id": "flow-dds.fact_flight_sales-dm.monthly_overview-55", + "from": "dds.fact_flight_sales", + "to": "dm.monthly_overview", + "label": "Агрегация сегментов", + "join": "Одна модель самолета за месяц. По ТЗ: пересчитывать затронутые месяцы; сначала считать показатели рейса, затем месяца. Без фактов нет входных строк для агрегата.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "dds.fact_flight_sales", + "columns": [ + "price", + "is_boarded" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#33-dmmonthly_overview" + } + ] + }, + { + "id": "flow-dds.dim_calendar-dm.monthly_overview-56", + "from": "dds.dim_calendar", + "to": "dm.monthly_overview", + "label": "Измерение для витрины", + "join": "Дает дату и календарные признаки по calendar_sk. Ожидаемая связь по ТЗ; загрузка пока заглушка. Тип JOIN и общее поведение при пропуске измерения ТЗ не задает.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "dds.dim_calendar", + "columns": [ + "calendar_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "calendar_sk" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#33-dmmonthly_overview" + } + ] + }, + { + "id": "flow-dds.dim_airplanes-dm.monthly_overview-57", + "from": "dds.dim_airplanes", + "to": "dm.monthly_overview", + "label": "Измерение для витрины", + "join": "Дает название модели и вместимость по airplane_sk. Ожидаемая связь по ТЗ; загрузка пока заглушка. Тип JOIN и общее поведение при пропуске измерения ТЗ не задает.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "dds.dim_airplanes", + "columns": [ + "airplane_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "airplane_sk" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#33-dmmonthly_overview" + } + ] + }, + { + "id": "flow-dds.dim_routes-dm.monthly_overview-58", + "from": "dds.dim_routes", + "to": "dm.monthly_overview", + "label": "Измерение для витрины", + "join": "Дает route_bk по route_sk для объединения версий маршрута. Ожидаемая связь по ТЗ; загрузка пока заглушка. Тип JOIN и общее поведение при пропуске измерения ТЗ не задает.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "dds.dim_routes", + "columns": [ + "route_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "route_sk" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#33-dmmonthly_overview" + } + ] + }, + { + "id": "flow-dds.fact_flight_sales-dm.passenger_loyalty-59", + "from": "dds.fact_flight_sales", + "to": "dm.passenger_loyalty", + "label": "Агрегация сегментов", + "join": "Один пассажир за всю историю. По ТЗ: пересчитывать всю историю пассажиров, чьи факты изменились. Строки без `passenger_sk` исключаются. Без фактов нет входных строк для агрегата.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "dds.fact_flight_sales", + "columns": [ + "price", + "is_boarded" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#34-dmpassenger_loyalty" + } + ] + }, + { + "id": "flow-dds.dim_passengers-dm.passenger_loyalty-60", + "from": "dds.dim_passengers", + "to": "dm.passenger_loyalty", + "label": "Измерение для витрины", + "join": "Дает исходный идентификатор и имя по passenger_sk. По ТЗ факты с NULL passenger_sk исключаются; загрузка пока заглушка.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "dds.dim_passengers", + "columns": [ + "passenger_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "passenger_sk" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#34-dmpassenger_loyalty" + } + ] + }, + { + "id": "flow-dds.dim_tariffs-dm.passenger_loyalty-61", + "from": "dds.dim_tariffs", + "to": "dm.passenger_loyalty", + "label": "Измерение для витрины", + "join": "Дает класс обслуживания по tariff_sk. Ожидаемая связь по ТЗ; загрузка пока заглушка. Тип JOIN и общее поведение при пропуске измерения ТЗ не задает.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "dds.dim_tariffs", + "columns": [ + "tariff_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "tariff_sk" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#34-dmpassenger_loyalty" + } + ] + }, + { + "id": "flow-dds.dim_routes-dm.passenger_loyalty-62", + "from": "dds.dim_routes", + "to": "dm.passenger_loyalty", + "label": "Измерение для витрины", + "join": "Дает route_bk по route_sk для объединения версий маршрута. Ожидаемая связь по ТЗ; загрузка пока заглушка. Тип JOIN и общее поведение при пропуске измерения ТЗ не задает.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "dds.dim_routes", + "columns": [ + "route_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "route_sk" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#34-dmpassenger_loyalty" + } + ] + }, + { + "id": "flow-dds.dim_calendar-dm.passenger_loyalty-63", + "from": "dds.dim_calendar", + "to": "dm.passenger_loyalty", + "label": "Измерение для витрины", + "join": "Дает дату и календарные признаки по calendar_sk. Ожидаемая связь по ТЗ; загрузка пока заглушка. Тип JOIN и общее поведение при пропуске измерения ТЗ не задает.", + "mode": "flow", + "status": "student", + "refs": [ + { + "node": "dds.dim_calendar", + "columns": [ + "calendar_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "calendar_sk" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../assignment/analyst_spec.md#34-dmpassenger_loyalty" + } + ] + }, + { + "id": "keys-bookings.bookings-bookings.tickets-64", + "from": "bookings.bookings", + "to": "bookings.tickets", + "label": "Бронирование → билеты", + "join": "`bookings.book_ref = tickets.book_ref`: одно бронирование объединяет несколько билетов. Само сопоставление ничего не загружает. Это логическая связь, не объявление FOREIGN KEY и не выбор типа JOIN.", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "bookings.bookings", + "columns": [ + "book_ref" + ] + }, + { + "node": "bookings.tickets", + "columns": [ + "book_ref" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../bookings/README.md" + } + ] + }, + { + "id": "keys-bookings.tickets-bookings.segments-65", + "from": "bookings.tickets", + "to": "bookings.segments", + "label": "Билет → сегменты", + "join": "`tickets.ticket_no = segments.ticket_no`: билет с пересадкой дает несколько сегментов. Это логическая связь, не объявление FOREIGN KEY и не выбор типа JOIN.", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "bookings.tickets", + "columns": [ + "ticket_no" + ] + }, + { + "node": "bookings.segments", + "columns": [ + "ticket_no" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../bookings/README.md" + } + ] + }, + { + "id": "keys-bookings.flights-bookings.segments-66", + "from": "bookings.flights", + "to": "bookings.segments", + "label": "Рейс → сегменты", + "join": "`flights.flight_id = segments.flight_id`: один рейс связан с сегментами разных билетов. Это логическая связь, не объявление FOREIGN KEY и не выбор типа JOIN.", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "bookings.flights", + "columns": [ + "flight_id" + ] + }, + { + "node": "bookings.segments", + "columns": [ + "flight_id" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../bookings/README.md" + } + ] + }, + { + "id": "keys-bookings.segments-bookings.boarding_passes-67", + "from": "bookings.segments", + "to": "bookings.boarding_passes", + "label": "Сегмент → талон", + "join": "Совпадают `ticket_no` И `flight_id`. У сегмента может не быть талона; талон другого рейса того же билета не подходит. Это логическая связь, не объявление FOREIGN KEY и не выбор типа JOIN.", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "bookings.segments", + "columns": [ + "ticket_no", + "flight_id" + ] + }, + { + "node": "bookings.boarding_passes", + "columns": [ + "ticket_no", + "flight_id" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../bookings/README.md" + } + ] + }, + { + "id": "keys-bookings.routes-bookings.flights-68", + "from": "bookings.routes", + "to": "bookings.flights", + "label": "Маршрут → рейсы", + "join": "`routes.route_no = flights.route_no` связывает рейс с маршрутом, но route_no в routes не уникален: есть варианты validity. Простой JOIN только по номеру может размножить рейс. В сборке факта ODS-маршрут сначала сокращается до одной строки. Это логическая связь, не объявление FOREIGN KEY и не выбор типа JOIN.", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "bookings.routes", + "columns": [ + "route_no", + "validity" + ] + }, + { + "node": "bookings.flights", + "columns": [ + "route_no" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../bookings/README.md" + } + ] + }, + { + "id": "keys-bookings.airports_data-bookings.routes-69", + "from": "bookings.airports_data", + "to": "bookings.routes", + "label": "Аэропорт вылета → маршруты", + "join": "`airports_data.airport_code = routes.departure_airport`: один аэропорт является местом вылета многих маршрутов. Это логическая связь, не объявление FOREIGN KEY и не выбор типа JOIN.", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "bookings.airports_data", + "columns": [ + "airport_code" + ] + }, + { + "node": "bookings.routes", + "columns": [ + "departure_airport" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../bookings/README.md" + } + ] + }, + { + "id": "keys-bookings.airports_data-bookings.routes-70", + "from": "bookings.airports_data", + "to": "bookings.routes", + "label": "Аэропорт прилета → маршруты", + "join": "`airports_data.airport_code = routes.arrival_airport`: тот же справочник играет роль места прилета. Это логическая связь, не объявление FOREIGN KEY и не выбор типа JOIN.", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "bookings.airports_data", + "columns": [ + "airport_code" + ] + }, + { + "node": "bookings.routes", + "columns": [ + "arrival_airport" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../bookings/README.md" + } + ] + }, + { + "id": "keys-bookings.airplanes_data-bookings.seats-71", + "from": "bookings.airplanes_data", + "to": "bookings.seats", + "label": "Модель → места", + "join": "`airplanes_data.airplane_code = seats.airplane_code`: одной модели соответствует много мест. Номер seat_no сам по себе модель не определяет. Это логическая связь, не объявление FOREIGN KEY и не выбор типа JOIN.", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "bookings.airplanes_data", + "columns": [ + "airplane_code" + ] + }, + { + "node": "bookings.seats", + "columns": [ + "airplane_code", + "seat_no" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../bookings/README.md" + } + ] + }, + { + "id": "keys-bookings.airplanes_data-bookings.routes-72", + "from": "bookings.airplanes_data", + "to": "bookings.routes", + "label": "Модель → маршруты", + "join": "`airplanes_data.airplane_code = routes.airplane_code`: одна модель используется на разных маршрутах. Это логическая связь, не объявление FOREIGN KEY и не выбор типа JOIN.", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "bookings.airplanes_data", + "columns": [ + "airplane_code" + ] + }, + { + "node": "bookings.routes", + "columns": [ + "airplane_code" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../bookings/README.md" + } + ] + }, + { + "id": "keys-ods.bookings-ods.tickets-73", + "from": "ods.bookings", + "to": "ods.tickets", + "label": "Бронирование → билеты", + "join": "`bookings.book_ref = tickets.book_ref`: одно бронирование объединяет несколько билетов. Само сопоставление ничего не загружает. Это логическая связь, не объявление FOREIGN KEY и не выбор типа JOIN.", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "ods.bookings", + "columns": [ + "book_ref" + ] + }, + { + "node": "ods.tickets", + "columns": [ + "book_ref" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/ods/tickets_ddl.sql" + } + ] + }, + { + "id": "keys-ods.tickets-ods.segments-74", + "from": "ods.tickets", + "to": "ods.segments", + "label": "Билет → сегменты", + "join": "`tickets.ticket_no = segments.ticket_no`: билет с пересадкой дает несколько сегментов. Это логическая связь, не объявление FOREIGN KEY и не выбор типа JOIN.", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "ods.tickets", + "columns": [ + "ticket_no" + ] + }, + { + "node": "ods.segments", + "columns": [ + "ticket_no" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/ods/segments_ddl.sql" + } + ] + }, + { + "id": "keys-ods.flights-ods.segments-75", + "from": "ods.flights", + "to": "ods.segments", + "label": "Рейс → сегменты", + "join": "`flights.flight_id = segments.flight_id`: один рейс связан с сегментами разных билетов. Это логическая связь, не объявление FOREIGN KEY и не выбор типа JOIN.", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "ods.flights", + "columns": [ + "flight_id" + ] + }, + { + "node": "ods.segments", + "columns": [ + "flight_id" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/ods/segments_ddl.sql" + } + ] + }, + { + "id": "keys-ods.segments-ods.boarding_passes-76", + "from": "ods.segments", + "to": "ods.boarding_passes", + "label": "Сегмент → талон", + "join": "Совпадают `ticket_no` И `flight_id`. У сегмента может не быть талона; талон другого рейса того же билета не подходит. Это логическая связь, не объявление FOREIGN KEY и не выбор типа JOIN.", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "ods.segments", + "columns": [ + "ticket_no", + "flight_id" + ] + }, + { + "node": "ods.boarding_passes", + "columns": [ + "ticket_no", + "flight_id" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/ods/boarding_passes_ddl.sql" + } + ] + }, + { + "id": "keys-ods.routes-ods.flights-77", + "from": "ods.routes", + "to": "ods.flights", + "label": "Маршрут → рейсы", + "join": "`routes.route_no = flights.route_no` связывает рейс с маршрутом, но route_no в routes не уникален: есть варианты validity. Простой JOIN только по номеру может размножить рейс. В сборке факта ODS-маршрут сначала сокращается до одной строки. Это логическая связь, не объявление FOREIGN KEY и не выбор типа JOIN.", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "ods.routes", + "columns": [ + "route_no", + "validity" + ] + }, + { + "node": "ods.flights", + "columns": [ + "route_no" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/ods/flights_ddl.sql" + } + ] + }, + { + "id": "keys-ods.airports-ods.routes-78", + "from": "ods.airports", + "to": "ods.routes", + "label": "Аэропорт вылета → маршруты", + "join": "`airports.airport_code = routes.departure_airport`: один аэропорт является местом вылета многих маршрутов. Это логическая связь, не объявление FOREIGN KEY и не выбор типа JOIN.", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "ods.airports", + "columns": [ + "airport_code" + ] + }, + { + "node": "ods.routes", + "columns": [ + "departure_airport" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/ods/routes_ddl.sql" + } + ] + }, + { + "id": "keys-ods.airports-ods.routes-79", + "from": "ods.airports", + "to": "ods.routes", + "label": "Аэропорт прилета → маршруты", + "join": "`airports.airport_code = routes.arrival_airport`: тот же справочник играет роль места прилета. Это логическая связь, не объявление FOREIGN KEY и не выбор типа JOIN.", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "ods.airports", + "columns": [ + "airport_code" + ] + }, + { + "node": "ods.routes", + "columns": [ + "arrival_airport" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/ods/routes_ddl.sql" + } + ] + }, + { + "id": "keys-ods.airplanes-ods.seats-80", + "from": "ods.airplanes", + "to": "ods.seats", + "label": "Модель → места", + "join": "`airplanes.airplane_code = seats.airplane_code`: одной модели соответствует много мест. Номер seat_no сам по себе модель не определяет. Это логическая связь, не объявление FOREIGN KEY и не выбор типа JOIN.", + "mode": "keys", + "status": "student", + "refs": [ + { + "node": "ods.airplanes", + "columns": [ + "airplane_code" + ] + }, + { + "node": "ods.seats", + "columns": [ + "airplane_code", + "seat_no" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../../sql/ods/seats_ddl.sql" + } + ] + }, + { + "id": "keys-ods.airplanes-ods.routes-81", + "from": "ods.airplanes", + "to": "ods.routes", + "label": "Модель → маршруты", + "join": "`airplanes.airplane_code = routes.airplane_code`: одна модель используется на разных маршрутах. Это логическая связь, не объявление FOREIGN KEY и не выбор типа JOIN.", + "mode": "keys", + "status": "student", + "refs": [ + { + "node": "ods.airplanes", + "columns": [ + "airplane_code" + ] + }, + { + "node": "ods.routes", + "columns": [ + "airplane_code" + ] + } + ], + "links": [ + { + "label": "ТЗ", + "url": "../../sql/ods/routes_ddl.sql" + } + ] + }, + { + "id": "keys-dds.dim_calendar-dds.fact_flight_sales-82", + "from": "dds.dim_calendar", + "to": "dds.fact_flight_sales", + "label": "Дата вылета", + "join": "`dim_calendar.calendar_sk = fact_flight_sales.calendar_sk`: одна строка измерения описывает много сегментов. LEFT JOIN при вставке факта допускает незаполненный ключ; подробный путь получения SK показан в виде «Поток данных».", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "dds.dim_calendar", + "columns": [ + "calendar_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "calendar_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "keys-dds.dim_airports-dds.fact_flight_sales-83", + "from": "dds.dim_airports", + "to": "dds.fact_flight_sales", + "label": "Аэропорт вылета", + "join": "`dim_airports.airport_sk = fact_flight_sales.departure_airport_sk`: одна строка измерения описывает много сегментов. LEFT JOIN при вставке факта допускает незаполненный ключ; подробный путь получения SK показан в виде «Поток данных».", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "dds.dim_airports", + "columns": [ + "airport_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "departure_airport_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "keys-dds.dim_airports-dds.fact_flight_sales-84", + "from": "dds.dim_airports", + "to": "dds.fact_flight_sales", + "label": "Аэропорт прилета", + "join": "`dim_airports.airport_sk = fact_flight_sales.arrival_airport_sk`: одна строка измерения описывает много сегментов. LEFT JOIN при вставке факта допускает незаполненный ключ; подробный путь получения SK показан в виде «Поток данных».", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "dds.dim_airports", + "columns": [ + "airport_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "arrival_airport_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "keys-dds.dim_airplanes-dds.fact_flight_sales-85", + "from": "dds.dim_airplanes", + "to": "dds.fact_flight_sales", + "label": "Модель самолета", + "join": "`dim_airplanes.airplane_sk = fact_flight_sales.airplane_sk`: одна строка измерения описывает много сегментов. LEFT JOIN при вставке факта допускает незаполненный ключ; подробный путь получения SK показан в виде «Поток данных».", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "dds.dim_airplanes", + "columns": [ + "airplane_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "airplane_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "keys-dds.dim_tariffs-dds.fact_flight_sales-86", + "from": "dds.dim_tariffs", + "to": "dds.fact_flight_sales", + "label": "Класс обслуживания", + "join": "`dim_tariffs.tariff_sk = fact_flight_sales.tariff_sk`: одна строка измерения описывает много сегментов. LEFT JOIN при вставке факта допускает незаполненный ключ; подробный путь получения SK показан в виде «Поток данных».", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "dds.dim_tariffs", + "columns": [ + "tariff_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "tariff_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "keys-dds.dim_passengers-dds.fact_flight_sales-87", + "from": "dds.dim_passengers", + "to": "dds.fact_flight_sales", + "label": "Пассажир", + "join": "`dim_passengers.passenger_sk = fact_flight_sales.passenger_sk`: одна строка измерения описывает много сегментов. LEFT JOIN при вставке факта допускает незаполненный ключ; подробный путь получения SK показан в виде «Поток данных».", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "dds.dim_passengers", + "columns": [ + "passenger_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "passenger_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "keys-dds.dim_routes-dds.fact_flight_sales-88", + "from": "dds.dim_routes", + "to": "dds.fact_flight_sales", + "label": "Версия маршрута", + "join": "`dim_routes.route_sk = fact_flight_sales.route_sk`: одна строка измерения описывает много сегментов. LEFT JOIN при вставке факта допускает незаполненный ключ; подробный путь получения SK показан в виде «Поток данных».", + "mode": "keys", + "status": "ready", + "refs": [ + { + "node": "dds.dim_routes", + "columns": [ + "route_sk" + ] + }, + { + "node": "dds.fact_flight_sales", + "columns": [ + "route_sk" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/dds/fact_flight_sales_load.sql" + } + ] + }, + { + "id": "flow-ods.segments-ods.flights-89", + "from": "ods.segments", + "to": "ods.flights", + "label": "Граница отбора сегментов", + "join": "`MAX(ods.segments._load_ts)` задает нижнюю границу новых `stg.segments`. Их `flight_id` помогают добрать рейсы из истории STG. Это чтение водяного знака, а не загрузка рейсов из сегментов и не зависимость задач.", + "mode": "flow", + "status": "ready", + "refs": [ + { + "node": "ods.segments", + "columns": [ + "_load_ts" + ] + }, + { + "node": "stg.segments", + "columns": [ + "_load_ts", + "flight_id" + ] + } + ], + "links": [ + { + "label": "SQL", + "url": "../../sql/ods/flights_load.sql" + } + ] + } + ] +} diff --git a/docs/design/architecture-map/render.py b/docs/design/architecture-map/render.py new file mode 100644 index 0000000..32d92fd --- /dev/null +++ b/docs/design/architecture-map/render.py @@ -0,0 +1,160 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// + +"""Проверить данные карты и собрать автономный HTML без внешних зависимостей.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from urllib.parse import unquote, urlsplit + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[2] +OUTPUT = HERE.parent / "architecture-map.html" +MARKER = "/*__MAP_DATA__*/" +NOTICE = ( + "\n" +) + + +def headings(text: str) -> set[str]: + """Якоря Markdown для ссылок на существующие разделы документации.""" + result = set(re.findall(r' None: + """Сверить объекты, поля, статусы, входы готовых SQL и локальные ссылки.""" + nodes = {node["id"]: node for node in data["nodes"]} + if len(nodes) != len(data["nodes"]): + raise ValueError("Повторяется идентификатор объекта") + edge_ids = [e["id"] for e in data["edges"]] + if len(set(edge_ids)) != len(edge_ids): + raise ValueError("Повторяется идентификатор связи") + columns, externals, tables = {}, {}, set() + for path in sorted((ROOT / "sql").glob("*/*_ddl.sql")): + text = re.sub(r"--[^\n]*", "", path.read_text(encoding="utf-8")) + pattern = r"CREATE\s+(EXTERNAL\s+)?TABLE\s+(?:IF NOT EXISTS\s+)?(\w+\.\w+)\s*\((.*?)\n\)" + for external, table, body in re.findall(pattern, text, re.S | re.I): + columns[table] = set(re.findall(r"^\s*(\w+)\s+\w+", body, re.M)) + if external: + source = re.search(r"pxf://([^?]+)", text)[1] + columns[source] = columns[table] + externals[table] = source + else: + tables.add(table) + expected = tables | set(externals.values()) + if set(nodes) != expected: + raise ValueError(f"Карта и DDL расходятся: {set(nodes) ^ expected}") + parts = {part for n in nodes.values() for part in n["parts"]} + if parts != set(externals): + raise ValueError("В карточках STG должны быть представлены все внешние таблицы") + layers = {layer["id"] for layer in data["layers"]} + positions = set() + for node in nodes.values(): + if node["layer"] not in layers: + raise ValueError(f"Неизвестный слой: {node['id']}") + position = (node["layer"], node["order"]) + if position in positions: + raise ValueError(f"Объекты перекрываются: {position}") + positions.add(position) + for field in ("what", "grain", "keys", "fields", "fill", "links"): + if not node[field]: + raise ValueError(f"Пустое поле {field}: {node['id']}") + for field in node["fields"]: + if field["name"] not in columns[node["id"]]: + raise ValueError(f"Нет поля {node['id']}.{field['name']} в DDL/PXF") + if node["layer"] == "source": + continue + layer, name = node["id"].split(".") + for role in ("load", "dq"): + path = ROOT / f"sql/{layer}/{name}_{role}.sql" + sql = re.sub(r"--[^\n]*", "", path.read_text(encoding="utf-8")).strip() + stub = sql.upper() == "SELECT 1;" + if stub != (node["status"] == "student"): + raise ValueError( + f"Статус карты не совпадает с {path.relative_to(ROOT)}" + ) + if role != "load" or stub: + continue + sources = set(re.findall(r"\b(?:FROM|JOIN)\s+(\w+\.\w+)", sql, re.I)) + sources = {externals.get(s, s) for s in sources if s != node["id"]} + sources &= expected + drawn = { + e["from"] + for e in data["edges"] + if e["mode"] == "flow" and e["to"] == node["id"] + } + if sources != drawn: + raise ValueError( + f"Входы SQL и карты расходятся у {node['id']}: {sources ^ drawn}" + ) + for edge in data["edges"]: + if edge["from"] not in nodes or edge["to"] not in nodes: + raise ValueError(f"Неизвестный конец связи {edge['id']}") + if edge["mode"] not in ("flow", "keys") or not edge["join"] or not edge["refs"]: + raise ValueError(f"Неполное описание связи {edge['id']}") + for ref in edge["refs"]: + if ( + ref["node"] not in nodes + or not set(ref["columns"]) <= columns[ref["node"]] + ): + raise ValueError(f"Неизвестные поля связи {edge['id']}: {ref}") + count = 0 + for item in [*nodes.values(), *data["edges"]]: + for link in item["links"]: + url = urlsplit(link["url"]) + if url.scheme or url.netloc: + raise ValueError(f"Ссылка должна вести в локальный репозиторий: {link}") + path = (OUTPUT.parent / unquote(url.path)).resolve() + if not path.is_relative_to(ROOT) or not path.is_file(): + raise ValueError(f"Файл по ссылке не найден: {link['url']}") + if url.fragment and unquote(url.fragment) not in headings( + path.read_text(encoding="utf-8") + ): + raise ValueError(f"Раздел по ссылке не найден: {link['url']}") + count += 1 + print( + f"Проверены {len(nodes)} объектов, {len(edge_ids)} связей, {count} ссылок; поля, статусы и входы SQL совпадают" + ) + + +def main() -> int: + """Собрать HTML либо проверить его свежесть вместе с исходными данными.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="проверить без записи") + args = parser.parse_args() + try: + data = json.loads((HERE / "map.json").read_text(encoding="utf-8")) + validate(data) + template = (HERE / "template.html").read_text(encoding="utf-8") + if template.count(MARKER) != 1: + raise ValueError("В шаблоне должен быть ровно один маркер данных") + payload = json.dumps(data, ensure_ascii=False, indent=2).replace("<", "\\u003c") + result = NOTICE + template.replace(MARKER, payload) + if args.check: + if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != result: + raise ValueError( + "HTML устарел. Выполните uv run docs/design/architecture-map/render.py" + ) + print("HTML соответствует исходникам") + else: + OUTPUT.write_text(result, encoding="utf-8") + print(f"Собрана карта: {OUTPUT.relative_to(ROOT)}") + return 0 + except (ValueError, KeyError, OSError) as exc: + print(f"Не удалось собрать карту: {exc}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/design/architecture-map/template.html b/docs/design/architecture-map/template.html new file mode 100644 index 0000000..60cede3 --- /dev/null +++ b/docs/design/architecture-map/template.html @@ -0,0 +1,448 @@ + + + + + + Карта DWH авиаперевозок + + + +
+

+

+ +
+
+
+

+
+ Сплошная рамка: готовоПунктирная рамка: задание + Синий: источникиЗеленый: потребителиОранжевый: выбор +
+
+
+
+ +
+ +
+ + + + diff --git a/docs/design/db_schema.md b/docs/design/db_schema.md index 4eccb71..7accb56 100644 --- a/docs/design/db_schema.md +++ b/docs/design/db_schema.md @@ -1,399 +1,207 @@ -# Схема БД DWH (Bookings → Greenplum) +# Как устроено хранилище авиаперевозок -> **Статус:** Все слои реализованы в ветке `solution` (STG, ODS, DDS, DM). -> На ветке `main` студенческие таблицы ODS (`airplanes`, `seats`), DDS-измерения -> (`dim_airplanes`, `dim_passengers`, `dim_routes`) и студенческие DM-витрины -> (`airport_traffic`, `route_performance`, `monthly_overview`, `passenger_loyalty`) -> — заглушки (`SELECT 1;`). Данные появятся после реализации заданий. +Разберем одну поездку: как бронирование связано с билетами и перелетами, +какие строки попадут в факт и как сложится выручка по направлениям. +Найти эти строки в структуре хранилища поможет [интерактивная карта](architecture-map.html). +Карта хранит подробности объектов и связей, а это руководство задает маршрут чтения. -Архитектура хранилища данных (DWH) для учебного проекта Airflow + Greenplum. -Источник — демо-БД `bookings` (Postgres). Одна диаграмма отвечает на один вопрос: -карта, которая показывает всё сразу, не даёт ясного ответа ни на один из них. +## Как открыть карту -Понять, как устроен проект → лесенка ниже. +Из локальной копии репозитория откройте `docs/design/architecture-map.html` +в браузере: через файловый менеджер или командой из корня проекта: -Пишете свой load-скрипт и хотите свериться → [`reading_the_pipeline.md`](reading_the_pipeline.md). +```bash +xdg-open docs/design/architecture-map.html +``` -Какие DQ-проверки писать → [`dq_taxonomy.md`](../reference/dq_taxonomy.md). +На Windows можно открыть этот же файл в Проводнике. Сервер, интернет и работающий +Docker для карты не нужны. Храните HTML рядом с остальными файлами репозитория: +ссылки из карточек ведут в локальные SQL и Markdown. +Браузер может показать Markdown как текст, а SQL предложить скачать. +Скачанный файл можно открыть в редакторе; для форматированного чтения +руководства используйте Markdown preview. -Точечный вопрос «что кормит X?» → полная карта в конце. +**В Gitea ссылка на HTML обычно показывает исходный текст, а не карту.** +Сначала откройте локальный HTML. Адресная ссылка вида +`architecture-map.html#node=dds.fact_flight_sales` сразу выбирает таблицу; +ее можно открыть из локального просмотра этого руководства или добавить +часть после `#` к адресу уже открытой карты. ---- +## Бронирование, билет и перелет + +Ниже **учебный пример**, придуманный для объяснения. Это не выгрузка из стенда. +Короткие обозначения `T1`, `F101`, `R1` заменяют настоящие номера. + +Аня и Борис купили билеты в одном бронировании `B1` от 30 августа. +Аня летит 1 сентября из Москвы через Петербург в Казань. Борис летит +тем же первым рейсом до Петербурга. Все сегменты - Economy. + +| Таблица источника | Пример строк | Что различают строки | +|---|---|---| +| `bookings.bookings` | `B1`, сумма `12000` | Одно бронирование на всю покупку | +| `bookings.tickets` | `T1 → B1, Аня`; `T2 → B1, Борис` | Два билета, по одному на пассажира | +| `bookings.segments` | `(T1, F101), 3000`; `(T1, F102), 5000`; `(T2, F101), 4000` | Три оплаченных перелета | +| `bookings.flights` | `F101 → R1`, 1 сентября; `F102 → R2`, 1 сентября | Два конкретных рейса | +| `bookings.routes` | `R1: SVO → LED`; `R2: LED → KZN` | Маршруты и варианты их расписания | + +Бронирование объединяет покупку. Билет принадлежит пассажиру и может включать +несколько сегментов. Сегмент соединяет билет с конкретным рейсом и хранит цену +этого перелета. Поэтому `ticket_no` недостаточно для поиска сегмента: +нужна пара `(ticket_no, flight_id)`. + +Рейс и маршрут тоже различаются. `flight_id` обозначает один вылет, +а `route_no` повторяется у рейсов в разные дни. В `routes` у одного номера +могут быть разные варианты расписания; строку различает пара +`(route_no, validity)`. + +Откройте [билеты](architecture-map.html#node=bookings.tickets&view=keys) +в виде **"Связи по ключам"**. Выделены бронирование, сегменты и связанные с ними +талоны. Выберите связь билета с сегментами: в карточке виден `ticket_no`. +Для связи сегмента с талоном понадобятся оба поля ключа, иначе талон +первого рейса можно ошибочно присоединить ко второму. ## Как устроен поток целиком? -Здесь показано движение данных через пять слоёв DWH. +Оставьте билет выбранным и переключитесь на **"Поток данных"**. +Таблицы останутся на местах, выбранный билет сохранится; теперь стрелки +показывают чтение данных загрузчиками. Синий цвет отмечает источники выбранной +таблицы, зеленый - потребителей, оранжевый - сам выбор. Несвязанные объекты +остаются видны приглушенными. Повторный выбор, **"Общий вид"** или `Esc` +возвращает обзор. -```mermaid -graph LR - SRC["Source
Postgres bookings"] --> STG["STG
сырые данные"] - STG --> ODS["ODS
очищенные данные и типы"] - ODS --> DDS["DDS
факты и измерения"] - DDS --> DM["DM
витрины для бизнес-вопросов"] -``` +Проследите путь +[сегмента](architecture-map.html#node=bookings.segments) через STG и ODS к факту: -## Как выглядит звезда? +- Источник → STG: PXF читает Postgres через внешние `stg.*_ext`. + Эти входы описаны в карточках STG и сами не хранят копию. Загрузчики + сохраняют строки в `stg.*` с бизнес-полями в TEXT и метками загрузки. +- STG → ODS: У строки появляются нужные типы. Например, + `stg.segments.price` становится `ods.segments.amount` типа NUMERIC. + ODS хранит текущее состояние строки по бизнес-ключу. +- ODS → DDS → DM: Сегмент обогащается ключами измерений в факте, + затем участвует в агрегатах витрины. -Здесь показан факт и шесть его измерений. - -```mermaid -graph TD - CAL["dds.dim_calendar"] --> FACT["dds.fact_flight_sales"] - AIRPORT["dds.dim_airports"] --> FACT - AIRPLANE["dds.dim_airplanes"] --> FACT - TARIFF["dds.dim_tariffs"] --> FACT - PASSENGER["dds.dim_passengers"] --> FACT - ROUTE["dds.dim_routes
SCD2"] --> FACT -``` +Стрелка данных не означает очередность задач Airflow. Например, календарь +генерируется без чтения ODS, хотя в DDS DAG задачи других измерений ждут +проверки календаря. Порядок выполнения смотрите по ссылке **"DAG · порядок задач"** +в карточке. Поля, SQL и проверки раскрываются там же, после выбора таблицы. ## Как собирается факт? -Здесь показаны таблицы, которые `fact_src` читает при сборке факта. -Сплошные стрелки обозначают `INNER JOIN`. Пунктирные стрелки обозначают `LEFT JOIN`. +Откройте [dds.fact_flight_sales](architecture-map.html#node=dds.fact_flight_sales). +В нашем примере в нем будет **три строки**, по одной на каждый сегмент. +Важные значения до выполнения студенческих заданий выглядят так: -```mermaid -graph LR - SEG["ods.segments"] -->|"Main stream: grain, price"| FACT["dds.fact_flight_sales"] - TKT["ods.tickets"] -->|"book_ref, passenger_id"| FACT - BKG["ods.bookings"] -->|"book_date"| FACT - FLT["ods.flights"] -->|"schedule, route_no"| FACT - ODS_RTE["ods.routes"] -.->|"dep/arr airports"| FACT - CAL["dds.dim_calendar"] -.->|"calendar_sk"| FACT - AIRPORT["dds.dim_airports"] -.->|"dep/arr _sk"| FACT - AIRPLANE["dds.dim_airplanes"] -.->|"airplane_sk"| FACT - TARIFF["dds.dim_tariffs"] -.->|"tariff_sk"| FACT - PASSENGER["dds.dim_passengers"] -.->|"passenger_sk"| FACT - ROUTE["dds.dim_routes"] -.->|"route_sk"| FACT - BOARDING["ods.boarding_passes"] -.->|"LEFT JOIN seat_no"| FACT -``` +| ticket_no | flight_id | price | departure_airport_sk | arrival_airport_sk | route_sk | airplane_sk | passenger_sk | +|---|---|---:|---|---|---|---|---| +| T1 | F101 | 3000 | SK аэропорта SVO | SK аэропорта LED | NULL | NULL | NULL | +| T1 | F102 | 5000 | SK аэропорта LED | SK аэропорта KZN | NULL | NULL | NULL | +| T2 | F101 | 4000 | SK аэропорта SVO | SK аэропорта LED | NULL | NULL | NULL | -`ods.routes` оставлен отдельным узлом: через него факт находит аэропорты -вылета и прилёта, не завися от студенческого `dim_routes`. Схема показывает -замысел загрузки. На ветке `main` поля `route_sk`, `airplane_sk` и -`passenger_sk` могут быть `NULL`, пока студент не реализовал измерения. -Почему это не всегда ошибка, объяснено в [справочнике DQ](../reference/dq_taxonomy.md#null--не-всегда-ошибка). +SK здесь обозначены словами: конкретные числа назначает загрузка измерений. +`calendar_sk` указывает на 1 сентября, `tariff_sk` - на Economy. +`book_ref = B1` повторяется во всех трех строках; `book_date` - 30 августа. +Цена хранится на уровне сегмента, а сумма `12000` из бронирования в факт +не переносится. Иначе ее можно было бы ошибочно посчитать трижды. + +Начальный поток - `ods.segments`. Соединения с билетами, бронированиями +и рейсами - `INNER JOIN`: отсутствие подходящей строки исключит сегмент +из вставки факта. Это потеря покрытия, которую должна обнаружить DQ. +Посадочный талон добавляется через `LEFT JOIN` по паре `(ticket_no, flight_id)`. +Нет талона - сегмент сохраняется, `seat_no` остается `NULL`, `is_boarded = false`. +В этом стенде признак посадки означает именно наличие талона. + +### Откуда берутся два аэропорта + +В карточке факта откройте связи **"Коды двух аэропортов"**, +**"Аэропорт вылета"** и **"Аэропорт прилета"**: + +1. Рейс дает `route_no`. Из `ods.routes` берется одна строка с наибольшим + `validity` на этот номер. В ODS поле `validity` хранится как TEXT; + этот выбор не использует дату рейса. +2. `departure_airport` и `arrival_airport` дают два кода аэропорта. +3. Один `dds.dim_airports` присоединяется дважды по `airport_bk`, в ролях + вылета и прилета. Получаются два разных поля SK в факте. + +Этот путь работает без студенческого `dim_routes`. Отсутствующий аэропорт +в факте - аномалия данных, а не ожидаемый результат невыполненного задания. +SQL использует `LEFT JOIN`, поэтому строка с таким пропуском сохранится; +допустимость пропуска отдельно оценивает [DQ факта](../../sql/dds/fact_flight_sales_dq.sql). + +### Какая версия маршрута нужна рейсу + +Связь **"Версия на дату рейса"** ведет к `dds.dim_routes` по `route_bk = route_no` +и дате `scheduled_departure::DATE`. Дата должна попасть в полуоткрытый +интервал `[valid_from, valid_to)`; `NULL` в конце означает открытую версию. +Рейс на границе двух версий относится к новой. + +Из выбранной версии берется `route_sk`, а ее `airplane_code` нужен для +поиска `airplane_sk` в `dim_airplanes`. Поэтому одного заполненного справочника +моделей недостаточно: без подходящей версии маршрута `airplane_sk` не появится. ## Что кормит витрины? -Здесь показано, во что расходятся строки факта. +Перейдите к [dm.sales_report](architecture-map.html#node=dm.sales_report). +Зерно меняется: день вылета × два аэропорта × класс обслуживания. +Три строки примера дадут две строки витрины: -```mermaid -graph LR - FACT["dds.fact_flight_sales"] -->|"день × аэропорты × тариф"| SALES["dm.sales_report
выручка и посадка"] - FACT -->|"день × аэропорт"| TRAFFIC["dm.airport_traffic
пассажиропоток"] - FACT -->|"route_bk"| ROUTE["dm.route_performance
эффективность маршрута"] - FACT -->|"месяц × airplane_sk"| MONTHLY["dm.monthly_overview
динамика по самолётам"] - FACT -->|"passenger_sk"| LOYALTY["dm.passenger_loyalty
профиль лояльности"] -``` +| Дата вылета | Направление | Класс | tickets_sold | total_revenue | +|---|---|---|---:|---:| +| 1 сентября | SVO → LED | Economy | 2 | 7000 | +| 1 сентября | LED → KZN | Economy | 1 | 5000 | -Измерения уточняют даты, аэропорты, тарифы, маршруты, самолёты и пассажиров, -но основной поток во все пять витрин начинается с `fact_flight_sales`. +`tickets_sold` здесь считает **сегменты**. Один билет может участвовать несколько раз. +Если сложить его по направлениям, получится 3 при двух билетах. +`total_revenue` складывается из цен сегментов, поэтому итог равен 12000. +Доля посадок рассчитывается по `is_boarded`. ---- +Готовая витрина использует календарь, две роли аэропорта и тариф через +`INNER JOIN`: факт без совпадения в одном из этих измерений не попадет в агрегат. +Поля `route_sk`, `airplane_sk`, `passenger_sk` ей не требуются, поэтому +эталон работает до выполнения заданий. Остальные витрины видны рядом +пунктирными рамками: в их карточках описан требуемый результат по ТЗ. -## Сводка объектов по слоям +## Модели самолетов и места: начало задания -### STG (Staging) +Откройте [dds.dim_airplanes](architecture-map.html#node=dds.dim_airplanes). +У него два источника: `ods.airplanes` дает характеристики модели, +а `ods.seats` - места, по которым считается вместимость. +Строка измерения описывает **модель**, например тип самолета, а не отдельный борт. +`seat_no` уникален только вместе с `airplane_code`. -Сырые данные из источника, все бизнес-поля как TEXT. Хранение: AO Row (zstd). -Служебные поля: `event_ts`, `_load_ts`, `_load_id`. - -| Таблица | Источник (PXF) | Ключ | Стратегия загрузки | Distribution | -|---------|---------------|------|-------------------|--------------| -| `stg.bookings` | `bookings.bookings` | `book_ref` | Инкремент (book_date) | `book_ref` | -| `stg.tickets` | `bookings.tickets` | `ticket_no` | Инкремент (через bookings) | `book_ref` | -| `stg.flights` | `bookings.flights` | `flight_id` | Инкремент (scheduled_departure) | `flight_id` | -| `stg.segments` | `bookings.segments` | `(ticket_no, flight_id)` | Инкремент (через tickets) | `ticket_no` | -| `stg.boarding_passes` | `bookings.boarding_passes` | `(ticket_no, flight_id)` | Инкремент (через tickets/bookings) | `ticket_no` | -| `stg.airports` | `bookings.airports_data` | `airport_code` | Full snapshot | `airport_code` | -| `stg.airplanes` | `bookings.airplanes_data` | `airplane_code` | Full snapshot | `airplane_code` | -| `stg.routes` | `bookings.routes` | `(route_no, validity)` | Full snapshot | `route_no` | -| `stg.seats` | `bookings.seats` | `(airplane_code, seat_no)` | Full snapshot | `airplane_code` | - -Детали: [`bookings_stg_design.md`](bookings_stg_design.md) - -### ODS (Operational Data Store) - -Очищенные данные с корректными типами. Справочники: AO Row (TRUNCATE+INSERT). -Транзакции: Heap (SCD1 UPSERT). Служебные поля: `_load_id`, `_load_ts`, -`event_ts` (для транзакционных таблиц). - -| Таблица | Стратегия | Storage | Ключевые преобразования | -|---------|-----------|---------|------------------------| -| `ods.bookings` | SCD1 UPSERT (HWM) | Heap | `total_amount` TEXT → NUMERIC | -| `ods.tickets` | SCD1 UPSERT (HWM) | Heap | `outbound` TEXT → BOOLEAN | -| `ods.flights` | SCD1 UPSERT (HWM) | Heap | TEXT → INTEGER, TIMESTAMP WITH TIME ZONE | -| `ods.segments` | SCD1 UPSERT (HWM) | Heap | `price` TEXT → `amount` NUMERIC | -| `ods.boarding_passes` | SCD1 UPSERT (HWM) | Heap | `boarding_no` TEXT → INTEGER | -| `ods.airports` | TRUNCATE+INSERT | AO Row | JSON → отдельные поля (`airport_name`, `city`) | -| `ods.airplanes` | TRUNCATE+INSERT | AO Row | `range`/`speed` TEXT → INTEGER | -| `ods.routes` | TRUNCATE+INSERT | AO Row | `days_of_week` → INTEGER[], `scheduled_time` → TIME | -| `ods.seats` | TRUNCATE+INSERT | AO Row | Без преобразований | - -Детали: [`bookings_ods_design.md`](bookings_ods_design.md) - -### DDS (Detailed Data Store — Star Schema) - -Измерения с суррогатными ключами. Факт в центре звезды. -SK генерация: `MAX(sk) + ROW_NUMBER()` (безопасно при `concurrency=1`). - -#### Измерения - -| Измерение | BK | SK | SCD | Storage | Источник | -|-----------|----|----|-----|---------|----------| -| `dim_calendar` | `date_actual` | `calendar_sk` | Static | AO Row | Генерация (2016–2030) | -| `dim_airports` | `airport_bk` | `airport_sk` | SCD1 | Heap | `ods.airports` | -| `dim_airplanes` | `airplane_bk` | `airplane_sk` | SCD1 | Heap | `ods.airplanes` + `ods.seats` (total_seats) | -| `dim_tariffs` | `fare_conditions` | `tariff_sk` | SCD1 | AO Row | `ods.segments` (DISTINCT) | -| `dim_passengers` | `passenger_id` | `passenger_sk` | SCD1 | Heap | `ods.tickets` (дедупликация) | -| `dim_routes` | `route_bk` | `route_sk` | **SCD2** | Heap | `ods.routes` + `dim_airports` + `dim_airplanes` | - -#### Факт - -`dds.fact_flight_sales` — зерно: 1 строка = 1 сегмент билета (`ticket_no` + `flight_id`). - -| FK | Lookup-путь | Примечание | -|----|-------------|------------| -| `calendar_sk` | `dim_calendar` | Дата вылета | -| `departure_airport_sk` | `ods.routes` → `dim_airports` | Аэропорт вылета (эталонный справочник) | -| `arrival_airport_sk` | `ods.routes` → `dim_airports` | Аэропорт прилёта (эталонный справочник) | -| `airplane_sk` | `dim_routes` → `dim_airplanes` | Самолёт (point-in-time SCD2) | -| `tariff_sk` | `dim_tariffs` | Тариф | -| `passenger_sk` | `dim_passengers` | Пассажир | -| `route_sk` | `dim_routes` | Версия маршрута (SCD2, point-in-time) | - -Метрики: `price` (NUMERIC), `is_boarded` (BOOLEAN). -Degenerate keys: `book_ref`, `ticket_no`, `flight_id`, `book_date`, `seat_no`. - -Детали: [`bookings_dds_design.md`](bookings_dds_design.md) - -### DM (Data Marts — Витрины) - -Аналитические витрины поверх DDS. Каждая отвечает на конкретный бизнес-вопрос. - -| Витрина | Бизнес-вопрос | Зерно | Стратегия | Storage | -|---------|--------------|-------|-----------|---------| -| `dm.sales_report` | Выручка и boarding rate по направлениям/тарифам/дням | (flight_date, dep_sk, arr_sk, tariff_sk) | UPSERT (HWM) | Heap | -| `dm.airport_traffic` | Пассажиропоток аэропортов по дням | (traffic_date, airport_sk) | UPSERT (HWM) | Heap | -| `dm.route_performance` | Эффективность маршрутов за всё время | route_bk | Full Rebuild | AO Column (zstd) | -| `dm.monthly_overview` | Помесячная динамика по типам самолётов | (year, month, airplane_sk) | UPSERT (HWM) | Heap | -| `dm.passenger_loyalty` | Профиль лояльности пассажиров | passenger_sk | UPSERT (затронутые ключи) | Heap | - -Детали: [`bookings_dm_design.md`](bookings_dm_design.md) - ---- +На `main` STG уже заполняется, а загрузки `ods.airplanes`, `ods.seats` +и `dds.dim_airplanes` пока содержат `SELECT 1;`. Карточки помечены +**"Задание"** и ведут к соответствующим пунктам ТЗ. Ожидаемые поля +известны из DDL, но готовой загрузки этих объектов еще нет. ## Ключевые договорённости -- **Нейминг полей**: [`naming_conventions.md`](naming_conventions.md) -- **DQ-проверки**: SQL-скрипты с `RAISE EXCEPTION` (не отдельный DQ-слой). - На ветке `main` студенческие DQ-скрипты (`airplanes_dq.sql`, `seats_dq.sql`, - student dims/DM) — заглушки (`SELECT 1;`) без проверок. -- **Инкремент STG**: для `tickets` опорная дата — из `bookings.book_date` -- **Point-in-time JOIN**: факт ↔ `dim_routes` по `[valid_from, valid_to)` -- **Суррогатные ключи**: `MAX(sk) + ROW_NUMBER()` (не SERIAL — GP-специфика) +- Ключи на карте логические. В DDL таблиц DWH нет ограничений + `PRIMARY KEY`, `UNIQUE` и `FOREIGN KEY`; уникальность и связи проверяют SQL + и DQ. `DISTRIBUTED BY` отвечает за размещение строк. +- `_load_id` и `_load_ts` описывают загрузку. `event_ts` - ее опорное время; + у снимков справочников в STG оно равно времени получения снимка. + Подробности - в [соглашениях об именах](naming_conventions.md). +- До реализации измерений допустимы `NULL` в `passenger_sk`, `route_sk` + и `airplane_sk`. Ключи фиксируются **при вставке** факта: повторный запуск + меняет цену и сведения о посадке, но не заполняет старые SK. + После выполнения заданий следуйте разделу + ["Пересчет факта после реализации измерений"](../assignment/analyst_spec.md#пересчёт-факта-после-реализации-измерений). +- В карточке заглушки описан результат из актуального ТЗ. Если ТЗ не определяет + тип соединения или поведение при пропуске строки, карта отмечает этот предел, + не выдает предположение за работающий SQL. ---- +## Куда идти дальше -## Обучающие материалы +Для первого задания разберите готовую загрузку справочника: +[как читать SQL загрузки](reading_the_pipeline.md), затем откройте +[учебные задания](../assignment/README.md). Для точечного вопроса +о таблице возвращайтесь на карту: там находится каталог объектов, +их источников, потребителей, SQL и DQ. -### Глоссарий - -| Термин | Объяснение | -|--------|-----------| -| **Зерно факта (Fact Grain)** | Минимальная единица в факте. Здесь — один сегмент билета. | -| **Суррогатный ключ (SK)** | Технический INT-ключ, генерируемый в DWH. | -| **Бизнес-ключ (BK)** | Ключ из источника (`airport_code`, `passenger_id`). | -| **Star Schema** | Факт в центре, измерения вокруг (без snowflake-подтаблиц). | -| **SCD Type 1** | Перезапись атрибутов без истории. | -| **SCD Type 2** | Версионирование: `valid_from`/`valid_to`, `hashdiff`. | -| **AO Row/Column** | Append-Only хранение (Row или Column). Не поддерживает UPDATE. | -| **Heap** | Стандартное хранение с поддержкой UPDATE/DELETE. | -| **HWM (High Water Mark)** | Отсечка по `MAX(_load_ts)` для инкрементальной загрузки. | - -### На что обратить внимание - -**Обогащение измерений:** -`seats` + `airplanes` → `dim_airplanes` — пример обогащения (total_seats). - -**«Майнинг» измерений из транзакций:** -`tickets` → `dim_passengers` — в источнике нет таблицы «Пассажиры». Извлекаем -уникальных пассажиров из билетов с дедупликацией. - -**Нормализация:** -`segments.fare_conditions` → `dim_tariffs` — выносим строковый атрибут в отдельный -справочник для компактного INT-ключа в факте. - -**Почему нет `dim_bookings`:** -`bookings` — транзакция, не справочник. `book_ref` и `book_date` хранятся -как degenerate keys в факте. - -**Point-in-time JOIN (SCD2):** -Факт присоединяется к той версии маршрута, которая действовала на дату вылета: -`scheduled_departure::DATE >= valid_from AND (valid_to IS NULL OR ... < valid_to)`. - ---- - -## Связанные документы - -- [`bookings_stg_design.md`](bookings_stg_design.md) — дизайн STG -- [`bookings_ods_design.md`](bookings_ods_design.md) — дизайн ODS (SCD1, batch contract, DQ) -- [`bookings_dds_design.md`](bookings_dds_design.md) — дизайн DDS (Star Schema, SCD2) -- [`bookings_dm_design.md`](bookings_dm_design.md) — дизайн DM (5 витрин) -- [`naming_conventions.md`](naming_conventions.md) — нейминг полей -- [`reading_the_pipeline.md`](reading_the_pipeline.md) — как читать связи из скрипта загрузки -- [`../reference/dq_taxonomy.md`](../reference/dq_taxonomy.md) — классы DQ-проверок и допустимые NULL -- Часовые пояса и настройка PXF — в ветке `solution` (`docs/reference/`) -- [`../assignment/analyst_spec.md`](../assignment/analyst_spec.md) — ТЗ от аналитика (курсовое задание) - ---- - -## Полная карта lineage (справочная) - -Здесь собран цельный взгляд «сверху». Эта карта нужна для точечного поиска: -например, «что кормит X?» или «куда идут данные из X?». Читать весь пайплайн -по ней подряд не нужно. - -```mermaid -graph LR - %% Стили - classDef source fill:#e1f5fe,stroke:#01579b,stroke-width:2px; - classDef stg fill:#fff9c4,stroke:#fbc02d,stroke-width:2px; - classDef ods fill:#e0f2f1,stroke:#00695c,stroke-width:2px; - classDef dim fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px; - classDef fact fill:#ffccbc,stroke:#bf360c,stroke-width:4px; - classDef dm fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px; - - %% 1. Source - subgraph Source_Postgres [Source: Postgres Bookings] - direction TB - SRC_Airports[airports_data]:::source - SRC_Airplanes[airplanes_data]:::source - SRC_Routes[routes]:::source - SRC_Seats[seats]:::source - SRC_Bookings[bookings]:::source - SRC_Tickets[tickets]:::source - SRC_Flights[flights]:::source - SRC_Segments[segments]:::source - SRC_Boarding[boarding_passes]:::source - end - - %% 2. STAGING (Load 1-to-1, AO-Row) - subgraph STG_Layer [Layer: STG Staging] - direction TB - STG_Airports[stg.airports]:::stg - STG_Airplanes[stg.airplanes]:::stg - STG_Routes[stg.routes]:::stg - STG_Seats[stg.seats]:::stg - STG_Bookings[stg.bookings]:::stg - STG_Tickets[stg.tickets]:::stg - STG_Flights[stg.flights]:::stg - STG_Segments[stg.segments]:::stg - STG_Boarding[stg.boarding_passes]:::stg - end - - %% Links Source to STG - SRC_Airports --> STG_Airports - SRC_Airplanes --> STG_Airplanes - SRC_Routes --> STG_Routes - SRC_Seats --> STG_Seats - SRC_Bookings --> STG_Bookings - SRC_Tickets --> STG_Tickets - SRC_Flights --> STG_Flights - SRC_Segments --> STG_Segments - SRC_Boarding --> STG_Boarding - - %% 3. ODS (3NF, Clean, Type) - subgraph ODS_Layer [Layer: ODS Operational Core] - direction TB - ODS_Airports[ods.airports]:::ods - ODS_Airplanes[ods.airplanes]:::ods - ODS_Routes[ods.routes]:::ods - ODS_Seats[ods.seats]:::ods - ODS_Bookings[ods.bookings]:::ods - ODS_Tickets[ods.tickets]:::ods - ODS_Flights[ods.flights]:::ods - ODS_Segments[ods.segments]:::ods - ODS_Boarding[ods.boarding_passes]:::ods - end - - %% Links STG to ODS - STG_Airports --> ODS_Airports - STG_Airplanes --> ODS_Airplanes - STG_Routes --> ODS_Routes - STG_Seats --> ODS_Seats - STG_Bookings --> ODS_Bookings - STG_Tickets --> ODS_Tickets - STG_Flights --> ODS_Flights - STG_Segments --> ODS_Segments - STG_Boarding --> ODS_Boarding - - %% 4. DDS (Star Schema) - subgraph DDS_Layer [Layer: DDS Star Schema] - direction TB - DIM_Calendar[dds.dim_calendar]:::dim - DIM_Airports[dds.dim_airports]:::dim - DIM_Airplanes[dds.dim_airplanes]:::dim - DIM_Tariffs[dds.dim_tariffs]:::dim - DIM_Passengers[dds.dim_passengers]:::dim - DIM_Routes[dds.dim_routes SCD2]:::dim - FACT_Sales[dds.fact_flight_sales]:::fact - end - - %% ODS to DDS Dimensions - ODS_Airports --> DIM_Airports - ODS_Airplanes --> DIM_Airplanes - ODS_Seats -.->|total_seats| DIM_Airplanes - ODS_Segments -.->|DISTINCT| DIM_Tariffs - ODS_Tickets -->|Unique passengers| DIM_Passengers - ODS_Routes -->|SCD2 hashdiff| DIM_Routes - DIM_Airports -.->|cities| DIM_Routes - DIM_Airplanes -.->|model, seats| DIM_Routes - - %% ODS to Fact - ODS_Segments -->|Main stream| FACT_Sales - ODS_Tickets -->|book_ref, passenger_id| FACT_Sales - ODS_Bookings -->|book_date| FACT_Sales - ODS_Flights -->|schedule, route_no| FACT_Sales - ODS_Routes -->|dep/arr airports| FACT_Sales - ODS_Boarding -->|LEFT JOIN seat_no| FACT_Sales - - %% Dimensions to Fact - DIM_Calendar -->|calendar_sk| FACT_Sales - DIM_Airports -->|dep/arr _sk| FACT_Sales - DIM_Airplanes -->|airplane_sk| FACT_Sales - DIM_Tariffs -->|tariff_sk| FACT_Sales - DIM_Passengers -->|passenger_sk| FACT_Sales - DIM_Routes -->|route_sk| FACT_Sales - - %% 5. DM (Vitrines) - subgraph DM_Layer [Layer: DM Data Marts] - direction TB - DM_Sales[dm.sales_report]:::dm - DM_Traffic[dm.airport_traffic]:::dm - DM_Route[dm.route_performance]:::dm - DM_Monthly[dm.monthly_overview]:::dm - DM_Loyalty[dm.passenger_loyalty]:::dm - end - - %% DDS to DM - FACT_Sales --> DM_Sales - FACT_Sales --> DM_Traffic - FACT_Sales --> DM_Route - FACT_Sales --> DM_Monthly - FACT_Sales --> DM_Loyalty - DIM_Airports -.-> DM_Sales - DIM_Airports -.-> DM_Traffic - DIM_Tariffs -.-> DM_Sales - DIM_Tariffs -.-> DM_Loyalty - DIM_Calendar -.-> DM_Sales - DIM_Calendar -.-> DM_Traffic - DIM_Calendar -.-> DM_Route - DIM_Calendar -.-> DM_Monthly - DIM_Calendar -.-> DM_Loyalty - DIM_Routes -.-> DM_Route - DIM_Routes -.-> DM_Monthly - DIM_Routes -.-> DM_Loyalty - DIM_Airplanes -.-> DM_Monthly - DIM_Passengers -.-> DM_Loyalty -``` +Для проверки результата пригодится [справочник DQ](../reference/dq_taxonomy.md). +Технические материалы по слоям остаются доступны: +[STG](bookings_stg_design.md), [ODS](bookings_ods_design.md), +[DDS](bookings_dds_design.md), [DM](bookings_dm_design.md). +В них есть планы разработки; текущее поведение проверяйте по SQL, +а требования к самостоятельной работе - по ТЗ.