diff --git a/README.md b/README.md
index 5dc3d80..bed9180 100644
--- a/README.md
+++ b/README.md
@@ -151,6 +151,8 @@ make clean # полный reset: удалить контейнер
## Документация
- [Учебные задания](docs/assignment/README.md)
+- [Схема DWH: четыре уровня приближения](docs/design/db_schema.md)
+- [Как читать пайплайн по скрипту загрузки](docs/design/reading_the_pipeline.md)
- [План тестирования/проверок и негативные кейсы](TESTING.md)
- [Дополнительные заметки и технические детали](docs/README.md)
- [Детали по STG DAG](docs/bookings_to_gp_stage.md)
diff --git a/docs/README.md b/docs/README.md
index 85b1e57..6808ab1 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -6,6 +6,7 @@
- [Быстрый старт и команды](../README.md)
- [Учебные задания](assignment/README.md)
+- [Как читать пайплайн по скрипту загрузки](design/reading_the_pipeline.md)
- [План тестирования и проверки](../TESTING.md)
- [Главный учебный DAG: bookings → stg](bookings_to_gp_stage.md)
- [Учебный DAG: stg -> ods](bookings_to_gp_ods.md)
@@ -16,6 +17,7 @@
- [Единые конвенции нейминга DWH (служебные поля и SCD)](design/naming_conventions.md)
- [Схема БД всех слоёв DWH](design/db_schema.md)
+- [Как читать пайплайн по скрипту загрузки](design/reading_the_pipeline.md)
- [Дизайн-документ STG](design/bookings_stg_design.md)
- [Дизайн-документ ODS](design/bookings_ods_design.md)
- [Дизайн-документ DDS](design/bookings_dds_design.md)
@@ -28,4 +30,5 @@
- [Как устроен Docker-стенд (образы, Connections, переменные окружения)](stack.md)
- [Известные проблемы bookings-db](reference/bookings_db_issues.md)
- [QA-план отладки пайплайна](reference/qa-plan.md)
+- [Классы DQ-проверок](reference/dq_taxonomy.md)
- [Порядок запуска DAG-ов](dag_execution_order.md)
diff --git a/docs/assignment/README.md b/docs/assignment/README.md
index 7bebcdd..d608434 100644
--- a/docs/assignment/README.md
+++ b/docs/assignment/README.md
@@ -9,6 +9,12 @@
На месте ваших заданий сейчас стоят **заглушки** (`SELECT 1;`). Вы замените
их на реальную SQL-логику, и после этого весь DWH будет заполнен.
+## С чего начать чтение
+
+Сначала посмотрите [схему DWH по уровням приближения](../design/db_schema.md).
+Пока пишете свой слой, держите рядом [метод чтения скрипта загрузки](../design/reading_the_pipeline.md).
+Для `*_dq.sql` сверяйтесь с [классами DQ-проверок](../reference/dq_taxonomy.md).
+
## Перед началом
Убедитесь, что стенд запущен и данные загружены —
diff --git a/docs/design/db_schema.md b/docs/design/db_schema.md
index 6ed8f58..4eccb71 100644
--- a/docs/design/db_schema.md
+++ b/docs/design/db_schema.md
@@ -7,157 +7,88 @@
> — заглушки (`SELECT 1;`). Данные появятся после реализации заданий.
Архитектура хранилища данных (DWH) для учебного проекта Airflow + Greenplum.
-Источник — демо-БД `bookings` (Postgres). Документ даёт цельный взгляд «сверху»;
-детали реализации — в дизайн-документах слоёв.
+Источник — демо-БД `bookings` (Postgres). Одна диаграмма отвечает на один вопрос:
+карта, которая показывает всё сразу, не даёт ясного ответа ни на один из них.
+
+Понять, как устроен проект → лесенка ниже.
+
+Пишете свой load-скрипт и хотите свериться → [`reading_the_pipeline.md`](reading_the_pipeline.md).
+
+Какие DQ-проверки писать → [`dq_taxonomy.md`](../reference/dq_taxonomy.md).
+
+Точечный вопрос «что кормит X?» → полная карта в конце.
---
-## Полная схема потоков данных (Data Lineage)
+## Как устроен поток целиком?
+
+Здесь показано движение данных через пять слоёв DWH.
```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
+ SRC["Source
Postgres bookings"] --> STG["STG
сырые данные"]
+ STG --> ODS["ODS
очищенные данные и типы"]
+ ODS --> DDS["DDS
факты и измерения"]
+ DDS --> DM["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
+```
+
+## Как собирается факт?
+
+Здесь показаны таблицы, которые `fact_src` читает при сборке факта.
+Сплошные стрелки обозначают `INNER JOIN`. Пунктирные стрелки обозначают `LEFT JOIN`.
+
+```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
+```
+
+`ods.routes` оставлен отдельным узлом: через него факт находит аэропорты
+вылета и прилёта, не завися от студенческого `dim_routes`. Схема показывает
+замысел загрузки. На ветке `main` поля `route_sk`, `airplane_sk` и
+`passenger_sk` могут быть `NULL`, пока студент не реализовал измерения.
+Почему это не всегда ошибка, объяснено в [справочнике DQ](../reference/dq_taxonomy.md#null--не-всегда-ошибка).
+
+## Что кормит витрины?
+
+Здесь показано, во что расходятся строки факта.
+
+```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
профиль лояльности"]
+```
+
+Измерения уточняют даты, аэропорты, тарифы, маршруты, самолёты и пассажиров,
+но основной поток во все пять витрин начинается с `fact_flight_sales`.
+
---
## Сводка объектов по слоям
@@ -310,5 +241,159 @@ Degenerate keys: `book_ref`, `ticket_no`, `flight_id`, `book_date`, `seat_no`.
- [`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
+```
diff --git a/docs/design/reading_the_pipeline.md b/docs/design/reading_the_pipeline.md
new file mode 100644
index 0000000..93bb085
--- /dev/null
+++ b/docs/design/reading_the_pipeline.md
@@ -0,0 +1,65 @@
+# Как читать пайплайн по скрипту загрузки
+
+Большую схему не нужно запоминать. Связи любой таблицы уже записаны в блоке
+`FROM ... JOIN ...` её файла `*_load.sql`. Обычно каждая прочитанная таблица
+даёт одно входящее ребро. Если одна таблица читается под разными псевдонимами,
+как `dim_airports`, её можно показать одним узлом.
+
+## Метод
+
+`INNER JOIN` показывает обязательную часть основного потока. Если входной строки
+нет, целевая строка не появится. `LEFT JOIN` к измерениям решает две задачи.
+Факт может прийти раньше измерения: это опаздывающее измерение
+(late-arriving dimension). Эталонный факт также не должен зависеть от того,
+успел ли студент реализовать свои измерения. Поэтому строка факта сохраняется,
+а её суррогатный ключ пока может быть `NULL`.
+
+В этом CTE есть и другие `LEFT JOIN`. `ods.routes` даёт эталонный путь к
+аэропортам, а `ods.boarding_passes` содержит необязательные `seat_no` и
+признак посадки.
+
+Если в загрузчике несколько SQL-операторов, смотрите на источник `INSERT`.
+Именно он задаёт полный список входящих связей для новых строк. Например, в
+`fact_flight_sales_load.sql` блок `FROM` у `UPDATE` читает только изменяемые
+поля, а полный список находится в CTE `fact_src` перед `INSERT`.
+
+## Пример: `fact_flight_sales`
+
+Откройте [`sql/dds/fact_flight_sales_load.sql`](../../sql/dds/fact_flight_sales_load.sql)
+и пройдите `fact_src` сверху вниз:
+
+1. `FROM ods.segments` задаёт зерно `(ticket_no, flight_id)` и поле `price`.
+2. `JOIN ods.tickets` обязателен для `book_ref` и `passenger_id`.
+3. `JOIN ods.bookings` обязателен для `book_date`.
+4. `JOIN ods.flights` обязателен для расписания и `route_no`.
+5. `LEFT JOIN ods.routes` находит аэропорты по `route_no`. Этот эталонный путь
+ не зависит от студенческого `dim_routes`.
+6. `LEFT JOIN dds.dim_routes` ищет point-in-time версию маршрута и `route_sk`.
+7. `LEFT JOIN dds.dim_calendar` даёт `calendar_sk` по дате вылета.
+8. Два `LEFT JOIN dds.dim_airports` дают ключи аэропортов вылета и прилёта.
+ На схеме их можно показать одним узлом `dim_airports`.
+9. `LEFT JOIN dds.dim_airplanes` даёт `airplane_sk` через найденную версию маршрута.
+10. `LEFT JOIN dds.dim_tariffs` даёт `tariff_sk`.
+11. `LEFT JOIN dds.dim_passengers` даёт `passenger_sk`.
+12. `LEFT JOIN ods.boarding_passes` даёт `seat_no` и признак `is_boarded`.
+
+Так список связей превращается в [схему «Как собирается факт?»](db_schema.md#как-собирается-факт):
+четыре обязательных входа рисуются сплошными стрелками, а остальные пунктирными.
+
+## Как проверить свой слой
+
+- Выпишите все таблицы из `FROM` и `JOIN` своего `*_load.sql`.
+- Сравните список с эталонной схемой или дизайн-документом этого слоя.
+- Проверьте служебные и SCD-поля по [`naming_conventions.md`](naming_conventions.md).
+- Убедитесь, что рядом есть `*_dq.sql` и он покрывает нужные
+ [классы DQ-проверок](../reference/dq_taxonomy.md).
+
+## Шаблон схемы одной таблицы
+
+Скопируйте шаблон и замените имена таблиц и полей:
+
+```mermaid
+graph LR
+ MAIN["schema.main_input"] -->|"grain, metric"| TARGET["schema.target"]
+ LOOKUP["schema.lookup"] -.->|"lookup_key"| TARGET
+```
diff --git a/docs/reference/dq_taxonomy.md b/docs/reference/dq_taxonomy.md
new file mode 100644
index 0000000..c2a2e60
--- /dev/null
+++ b/docs/reference/dq_taxonomy.md
@@ -0,0 +1,63 @@
+# Классы DQ-проверок
+
+DQ-проверка должна отвечать на конкретный вопрос. В этом проекте есть три
+класса. Один `*_dq.sql` может содержать проверки нескольких классов.
+
+## 1. Универсальные структурные проверки
+
+Они проверяют форму таблицы: обязательные ключи заполнены, ключ уникален, а
+таблица не пуста там, где по контракту ожидаются строки. Так можно быстро поймать
+сломанное зерно или незавершённую загрузку без знания бизнес-смысла данных.
+
+Реальные примеры:
+
+- [`sql/dds/dim_airports_dq.sql`](../../sql/dds/dim_airports_dq.sql) требует
+ непустую таблицу, проверяет уникальность `airport_sk` и `airport_bk`, а также
+ обязательные поля.
+- [`sql/ods/bookings_dq.sql`](../../sql/ods/bookings_dq.sql) ищет дубли
+ `book_ref` и `NULL` или пустые значения в обязательных полях. Пустой
+ инкрементальный батч здесь допустим.
+- [`sql/ods/tickets_dq.sql`](../../sql/ods/tickets_dq.sql) проверяет уникальность
+ `ticket_no`, обязательные поля и отсутствие билетов без `bookings.book_ref`.
+
+## 2. Проверки бизнес-правил
+
+Они проверяют ограничения предметной области, которых не видно из структуры
+таблицы. Такая проверка нужна, чтобы заметить правдоподобную по типам, но
+невозможную по смыслу строку.
+
+В [`sql/dm/sales_report_dq.sql`](../../sql/dm/sales_report_dq.sql) для дат
+текущего запуска проверяется, что `tickets_sold >= passengers_boarded`,
+`boarding_rate` лежит между `0` и `1`, а `total_revenue` неотрицательна.
+
+## 3. Межслойные сверки
+
+Они отвечают на вопрос: согласован ли слой N+1 со слоем N для нужного батча?
+Сверка находит потерянные или лишние строки на границе загрузки.
+
+Реальные примеры используют разные способы сверки:
+
+- [`sql/ods/airports_dq.sql`](../../sql/ods/airports_dq.sql) сравнивает множества
+ `airport_code`: ищет пропущенные и лишние ключи относительно текущего STG-батча.
+ Это сверка ключей, а не простое сравнение числа строк.
+- [`sql/stg/airplanes_dq.sql`](../../sql/stg/airplanes_dq.sql) сравнивает число
+ строк во внешней `stg.airplanes_ext` и в текущем батче `stg.airplanes`.
+- [`sql/dds/fact_flight_sales_dq.sql`](../../sql/dds/fact_flight_sales_dq.sql)
+ требует равенства числа строк факта и `ods.segments`, потому что зерно факта —
+ один сегмент билета.
+
+## `NULL` — не всегда ошибка
+
+В [`fact_flight_sales_load.sql`](../../sql/dds/fact_flight_sales_load.sql)
+измерения присоединяются через `LEFT JOIN`. Факт может прийти раньше измерения,
+поэтому `route_sk`, `airplane_sk` или `passenger_sk` временно бывают `NULL`. На
+ветке `main` это также ожидаемо, пока студент не реализовал эти измерения. Такой
+`NULL` сохраняет строку факта и не должен сам по себе останавливать пайплайн.
+
+`seat_no` тоже может быть `NULL`: у билета уже есть сегмент, но записи
+`boarding_passes` ещё нет. Обязательный ключ, напротив, должен быть заполнен.
+Например, `NULL` в `airport_sk` измерения или в `(ticket_no, flight_id)` факта
+нарушает контракт таблицы.
+
+Архитектурное решение о том, где живут DQ-проверки, зафиксировано в разделе
+[«Ключевые договорённости»](../design/db_schema.md#ключевые-договорённости).
diff --git a/docs/reference/qa-plan.md b/docs/reference/qa-plan.md
index 78f89c3..84a3ee2 100644
--- a/docs/reference/qa-plan.md
+++ b/docs/reference/qa-plan.md
@@ -14,6 +14,7 @@
>
> Зависимость: перед запуском этого плана нужно починить bookings-db
> (см. `docs/reference/bookings_db_issues.md`).
+> Классы проверок для разбора ошибок: [`dq_taxonomy.md`](dq_taxonomy.md).
---