docs(map): добавлены карта и руководство по структуре DWH
Объяснены зерно таблиц, связи и путь бронирования до витрины. Добавлена автономная карта с двумя видами связей и адресами объектов. Состояние заглушек сверено с DDL и ТЗ; SQL и DAG не изменялись. Проверки: сборка и ссылки, make lint, pytest (4 passed, 14 skipped), Chromium file:// offline: выбор, связи, сброс, история и узкий экран. Refs #2
This commit is contained in:
1 parent
77d72b08c6
commit
b9bcfe7230
9 files changed
+10806
-373
No files matched your search
@@ -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)
|
||||
|
||||
+7
-1
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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/пробел и вид на узком экране.
|
||||
На узком экране карта прокручивается, карточка находится под ней.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 = (
|
||||
"<!-- Собрано из architecture-map/map.json и template.html. "
|
||||
"Команда: uv run docs/design/architecture-map/render.py -->\n"
|
||||
)
|
||||
|
||||
|
||||
def headings(text: str) -> set[str]:
|
||||
"""Якоря Markdown для ссылок на существующие разделы документации."""
|
||||
result = set(re.findall(r'<a\s+id="([^"]+)"', text))
|
||||
for title in re.findall(r"^#{1,6}\s+(.+)$", text, re.M):
|
||||
slug = re.sub(r"[^\w\- ]", "", title.lower()).replace(" ", "-")
|
||||
result.add(slug)
|
||||
return result
|
||||
|
||||
|
||||
def validate(data: dict) -> 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())
|
||||
@@ -0,0 +1,448 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Карта DWH авиаперевозок</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--paper: #f7f7f5;
|
||||
--panel: #ffffff;
|
||||
--ink: #272a2d;
|
||||
--muted: #6b7177;
|
||||
--line: #8c9298;
|
||||
--soft: #e6e8e9;
|
||||
--accent: #b54a2a;
|
||||
--accent-soft: #f5ded7;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
header { padding: 22px 26px 12px; }
|
||||
h1 { margin: 0 0 5px; font-size: 25px; }
|
||||
.lead { margin: 0; color: var(--muted); }
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 26px 16px;
|
||||
}
|
||||
.switcher { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
button {
|
||||
border: 1px solid #aeb3b7;
|
||||
border-radius: 18px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
padding: 6px 13px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
button[aria-pressed="true"] {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
color: #71301e;
|
||||
}
|
||||
button:focus-visible, [tabindex="0"]:focus-visible {
|
||||
outline: 3px solid #d79078;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 15px; color: var(--muted); }
|
||||
.legend-item { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.legend-line { width: 34px; border-top: 2px solid var(--line); }
|
||||
.legend-line.dashed { border-top-style: dashed; }
|
||||
.legend-line.dotted { border-top-style: dotted; }
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 370px;
|
||||
gap: 16px;
|
||||
padding: 0 18px 24px;
|
||||
align-items: start;
|
||||
}
|
||||
.map-scroll {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #d7d9da;
|
||||
border-radius: 10px;
|
||||
background: var(--panel);
|
||||
}
|
||||
svg { display: block; width: 100%; min-width: 1040px; height: auto; }
|
||||
.layer-title { font-size: 15px; font-weight: 700; text-anchor: middle; }
|
||||
.layer-caption { font-size: 11px; fill: var(--muted); text-anchor: middle; }
|
||||
.zone-caption { font-size: 12px; fill: var(--muted); }
|
||||
.column-guide { stroke: #eef0f1; stroke-width: 1; }
|
||||
.edge-line {
|
||||
fill: none;
|
||||
stroke: var(--line);
|
||||
stroke-width: 1.8;
|
||||
marker-end: url(#arrowhead);
|
||||
transition: opacity .15s, stroke .15s, stroke-width .15s;
|
||||
}
|
||||
.edge-line.dashed { stroke-dasharray: 8 6; }
|
||||
.edge-line.dotted { stroke-dasharray: 2 6; stroke-linecap: round; }
|
||||
.edge-hit { fill: none; stroke: transparent; stroke-width: 14; cursor: pointer; }
|
||||
.node { cursor: pointer; transition: opacity .15s; }
|
||||
.node rect {
|
||||
fill: #f1f2f2;
|
||||
stroke: #858b90;
|
||||
stroke-width: 1.2;
|
||||
rx: 7;
|
||||
transition: fill .15s, stroke .15s, stroke-width .15s;
|
||||
}
|
||||
.node text {
|
||||
fill: var(--ink);
|
||||
font: 14px ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
}
|
||||
.node .stream-mark { fill: var(--muted); font: 11px system-ui, sans-serif; }
|
||||
.is-filtered, .is-context-dim { opacity: .16; }
|
||||
.is-path:not(.is-filtered), .is-hover:not(.is-filtered) { opacity: 1; }
|
||||
.is-path .edge-line, .edge.is-path .edge-line {
|
||||
stroke: var(--accent); stroke-width: 2.8; marker-end: url(#arrowhead-active);
|
||||
}
|
||||
.node.is-path rect { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 2; }
|
||||
.node.is-hover rect { fill: #faefeb; stroke: #d79078; }
|
||||
.edge.is-hover .edge-line { stroke: #d79078; stroke-width: 2.2; }
|
||||
.node.is-selected rect { stroke-width: 3; }
|
||||
.edge.is-selected .edge-line { stroke-width: 4; }
|
||||
aside {
|
||||
min-height: 470px; max-height: calc(100vh - 32px); overflow-y: auto; position: sticky; top: 16px;
|
||||
padding: 20px;
|
||||
border: 1px solid #d7d9da;
|
||||
border-radius: 10px;
|
||||
background: var(--panel);
|
||||
}
|
||||
@media (max-width: 900px) { main { grid-template-columns: minmax(0, 1fr); } aside { min-height: 0; max-height: none; position: static; } .map-scroll { max-height: 50vh; overflow: auto; } }
|
||||
aside h2 { margin: 0 0 3px; font-size: 20px; overflow-wrap: anywhere; }
|
||||
.meta { margin: 0 0 18px; color: var(--muted); }
|
||||
.card-section { margin-top: 17px; }
|
||||
.card-section h3 { margin: 0 0 5px; font-size: 13px; }
|
||||
.card-section p { margin: 0; }
|
||||
.card-section ul { margin: 5px 0 0; padding-left: 20px; }
|
||||
.card-section li + li { margin-top: 5px; }
|
||||
code { font: .92em ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||
a { color: #8a351e; text-underline-offset: 2px; }
|
||||
.hint { color: var(--muted); font-size: 13px; }
|
||||
.edge[hidden] { display: none; }
|
||||
.node.student rect { stroke-dasharray: 5 3; }
|
||||
.node.student .stream-mark { fill: #875a14; }
|
||||
.edge.expected .edge-line { stroke-dasharray: 5 4; }
|
||||
.node.is-upstream rect { fill: #e3eff5; stroke: #307c9a; }
|
||||
.node.is-downstream rect { fill: #e7f1e5; stroke: #527a43; }
|
||||
.node.is-selected rect { fill: var(--accent-soft); stroke: var(--accent); }
|
||||
.relation-button { border-radius: 5px; padding: 5px 7px; text-align: left; width: 100%; margin-top: 5px; font-size: 13px; }
|
||||
.badge { font-size: 13px; padding: 3px 8px; border-radius: 4px; background: #eef1ed; display: inline-block; }
|
||||
.badge.student { background: #fff1cf; color: #775018; }
|
||||
.navigation { margin: 8px 0; }
|
||||
code { overflow-wrap: anywhere; }
|
||||
summary { cursor: pointer; font-weight: 600; }
|
||||
#view-hint { margin: 0; max-width: 1050px; }
|
||||
noscript { display: block; padding: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1 id="page-title"></h1>
|
||||
<p class="lead" id="lead"></p>
|
||||
<p class="navigation"><a href="db_schema.md">Руководство: от бронирования до витрины</a> · Учебная ветка main</p>
|
||||
</header>
|
||||
<div class="controls">
|
||||
<div class="switcher" id="switcher" aria-label="Вид связей"></div>
|
||||
<p id="view-hint" class="hint"></p>
|
||||
<div class="legend" aria-label="Обозначения">
|
||||
<span>Сплошная рамка: готово</span><span>Пунктирная рамка: задание</span>
|
||||
<span id="upstream-legend">Синий: источники</span><span id="downstream-legend">Зеленый: потребители</span><span>Оранжевый: выбор</span>
|
||||
</div>
|
||||
</div>
|
||||
<main>
|
||||
<div class="map-scroll">
|
||||
<svg id="map" viewBox="0 0 1250 725" role="group" aria-label="Путь данных через слои хранилища"></svg>
|
||||
</div>
|
||||
<aside id="details" aria-live="polite" aria-label="Карточка выбранного объекта"></aside>
|
||||
</main>
|
||||
<noscript>Для интерактивной карты включите JavaScript. Объяснение модели доступно в <a href="db_schema.md">руководстве</a>.</noscript>
|
||||
<script>
|
||||
const MAP = /*__MAP_DATA__*/;
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const state = { view: "flow", selected: null };
|
||||
const nodeById = new Map(MAP.nodes.map(node => [node.id, node]));
|
||||
const edgeById = new Map(MAP.edges.map(edge => [edge.id, edge]));
|
||||
const positions = new Map();
|
||||
const svg = document.getElementById("map");
|
||||
const details = document.getElementById("details");
|
||||
const statusLabel = {ready: "Готовая загрузка", student: "Задание · загрузка и DQ — заглушки", source: "Источник · демо-БД"};
|
||||
function svgElement(name, attributes = {}) {
|
||||
const element = document.createElementNS(NS, name);
|
||||
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, value));
|
||||
return element;
|
||||
}
|
||||
function layerTitle(layerId) { return MAP.layers.find((layer) => layer.id === layerId)?.title ?? ""; }
|
||||
|
||||
function appendRichText(parent, value) {
|
||||
value.split("`").forEach((part, index) => {
|
||||
const element = index % 2 ? document.createElement("code") : document.createTextNode(part);
|
||||
if (index % 2) element.textContent = part;
|
||||
parent.append(element);
|
||||
});
|
||||
}
|
||||
function addSection(title, value) {
|
||||
if (!value || (Array.isArray(value) && !value.length)) return;
|
||||
const section = document.createElement("section");
|
||||
section.className = "card-section";
|
||||
const heading = document.createElement("h3");
|
||||
heading.textContent = title;
|
||||
section.append(heading);
|
||||
if (Array.isArray(value)) {
|
||||
const list = document.createElement("ul");
|
||||
value.forEach((item) => {
|
||||
const row = document.createElement("li");
|
||||
appendRichText(row, item);
|
||||
list.append(row);
|
||||
});
|
||||
section.append(list);
|
||||
} else {
|
||||
const paragraph = document.createElement("p");
|
||||
appendRichText(paragraph, value);
|
||||
section.append(paragraph);
|
||||
}
|
||||
details.append(section);
|
||||
}
|
||||
function addLinks(links) {
|
||||
if (!links?.length) return;
|
||||
const section = document.createElement("section");
|
||||
section.className = "card-section";
|
||||
const heading = document.createElement("h3");
|
||||
heading.textContent = "Ссылки";
|
||||
const list = document.createElement("ul");
|
||||
links.forEach((item) => {
|
||||
const row = document.createElement("li");
|
||||
const link = document.createElement("a");
|
||||
link.href = item.url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.textContent = item.label;
|
||||
row.append(link);
|
||||
list.append(row);
|
||||
});
|
||||
section.append(heading, list);
|
||||
details.append(section);
|
||||
}
|
||||
|
||||
function renderIntro(message = "") {
|
||||
details.replaceChildren();
|
||||
const heading = document.createElement("h2");
|
||||
heading.textContent = message || "Начните с билета";
|
||||
details.append(heading);
|
||||
addSection("Маршрут чтения", "Один билет может включать несколько перелетов. Выберите bookings.tickets, затем проследите сегменты до факта и витрины sales_report.");
|
||||
addSection("Как исследовать", "Выбор таблицы подсвечивает ее окружение: источники и потребителей в потоке данных, родителей и потомков в связях по ключам. Их положение остается прежним. Соседние связи доступны кнопками в карточке. Повторный выбор, Esc или «Общий вид» снимает выделение.");
|
||||
addSection("Что показывает карта", "Стрелки описывают данные. Порядок задач Airflow смотрите отдельно по ссылке на DAG в карточке. Пунктирная стрелка означает ожидаемую загрузку из задания.");
|
||||
addSection("Вход через PXF", "Внешние stg.*_ext показаны внутри карточек STG: они читают Postgres, но сами не хранят копию данных.");
|
||||
addSection("Ключи", "Все ключи и связи здесь логические. DDL таблиц DWH не объявляет PRIMARY KEY, UNIQUE и FOREIGN KEY. DISTRIBUTED BY задает размещение, а не уникальность.");
|
||||
addLinks([{label: "Открыть руководство о модели", url: "db_schema.md"}]);
|
||||
}
|
||||
function relationButtons(node) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "card-section";
|
||||
const h = document.createElement("h3"); h.textContent = "Связи рядом с таблицей";
|
||||
section.append(h);
|
||||
const edges = MAP.edges.filter(e => e.mode === state.view && (e.from === node.id || e.to === node.id));
|
||||
edges.forEach(edge => {
|
||||
const b = document.createElement("button"); b.className = "relation-button";
|
||||
const neighbor = edge.from === node.id ? edge.to : edge.from;
|
||||
b.textContent = `${edge.from === node.id ? "→" : "←"} ${neighbor}: ${edge.label}`;
|
||||
b.dataset.edge = edge.id;
|
||||
b.addEventListener("click", () => selectItem("edge", edge.id));
|
||||
section.append(b);
|
||||
});
|
||||
if (!edges.length) {
|
||||
const p = document.createElement("p"); p.textContent = "В этом виде рядом нет связей. Переключите вид, чтобы увидеть путь загрузки."; section.append(p);
|
||||
}
|
||||
details.append(section);
|
||||
}
|
||||
function renderNodeCard(node) {
|
||||
details.replaceChildren();
|
||||
const heading = document.createElement("h2"); heading.textContent = node.name;
|
||||
const meta = document.createElement("p"); meta.className = `badge ${node.status}`; meta.textContent = statusLabel[node.status];
|
||||
details.append(heading, meta);
|
||||
addSection("Назначение", node.what);
|
||||
addSection("Одна строка", node.grain);
|
||||
addSection("Логические ключи", node.keys);
|
||||
|
||||
addSection(node.status === "student" ? "Ожидаемый результат по ТЗ" : "Как наполняется", node.fill);
|
||||
if (node.parts.length) addSection("Вход через PXF", node.parts.map(id => `\`${id}\` — внешняя таблица для чтения Postgres, без локального хранения. Ее определение находится в том же DDL.`));
|
||||
const fields = document.createElement("details"); fields.className = "card-section";
|
||||
const summary = document.createElement("summary"); summary.textContent = `Важные поля (${node.fields.length})`;
|
||||
const list = document.createElement("ul");
|
||||
node.fields.forEach(field => {
|
||||
const li = document.createElement("li"); appendRichText(li, "`" + field.name + "` — " + field.meaning); list.append(li);
|
||||
});
|
||||
fields.append(summary,list); details.append(fields);
|
||||
relationButtons(node);
|
||||
const dag = {stg:"stage",ods:"ods",dds:"dds",dm:"dm"}[node.layer];
|
||||
addLinks([...node.links, ...(dag ? [{label:"DAG · порядок задач",url:`../../airflow/dags/bookings_to_gp_${dag}.py`}] : []), {label:"Ссылка на эту таблицу",url:`#node=${node.id}&view=${state.view}`}]);
|
||||
}
|
||||
function renderEdgeCard(edge) {
|
||||
details.replaceChildren();
|
||||
const heading = document.createElement("h2"); heading.textContent = edge.label; details.append(heading);
|
||||
addSection("Таблицы", "`" + edge.from + "` → `" + edge.to + "`");
|
||||
addSection("Вид связи", edge.mode === "flow" ? (edge.status === "student" ? "Ожидаемый поток по ТЗ; загрузка пока не реализована." : "Чтение и преобразование данных в готовом SQL.") : "Логическое сопоставление строк по ключам. Это не зависимость задач Airflow и не объявленное ограничение БД.");
|
||||
addSection("Что связывает", edge.join);
|
||||
addSection("Участвующие поля", edge.refs.map(r => `\`${r.node}\`: ${r.columns.map(c => "`"+c+"`").join(", ")}`));
|
||||
[edge.from, edge.to].forEach(id => {
|
||||
const b = document.createElement("button"); b.className = "relation-button"; b.textContent = `Открыть ${id}`;
|
||||
b.addEventListener("click", () => selectItem("node", id)); details.append(b);
|
||||
});
|
||||
addLinks(edge.links);
|
||||
}
|
||||
function layoutMap() {
|
||||
MAP.layers.forEach((layer, index) => {
|
||||
MAP.nodes.filter(node => node.layer === layer.id).forEach(node => {
|
||||
positions.set(node.id, {x: 20 + index * 250, y: 72 + node.order * 70, width: 195, height: 48});
|
||||
});
|
||||
});
|
||||
}
|
||||
function edgePath(edge, offset) {
|
||||
const a = positions.get(edge.from), b = positions.get(edge.to);
|
||||
const y1 = a.y + a.height / 2 + offset, y2 = b.y + b.height / 2 + offset;
|
||||
if (a.x === b.x) {
|
||||
const x = a.x + a.width, bend = x + 20 + Math.abs(a.y - b.y) * .08 + offset;
|
||||
return `M ${x} ${y1} C ${bend} ${y1}, ${bend} ${y2}, ${x} ${y2}`;
|
||||
}
|
||||
const x1 = a.x + a.width, x2 = b.x, bend = Math.max(18, (x2 - x1) / 2);
|
||||
return `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`;
|
||||
}
|
||||
function drawMap() {
|
||||
const defs = svgElement("defs");
|
||||
[["arrowhead", "#8c9298"], ["arrowhead-active", "#b54a2a"]].forEach(([id, fill]) => {
|
||||
const marker = svgElement("marker", {id, viewBox:"0 0 8 8", refX:"7", refY:"4", markerWidth:"6", markerHeight:"6", orient:"auto"});
|
||||
marker.append(svgElement("path", {d:"M 0 0 L 8 4 L 0 8 z", fill})); defs.append(marker);
|
||||
});
|
||||
svg.append(defs);
|
||||
MAP.layers.forEach((layer,index) => {
|
||||
const center = 117.5 + index * 250;
|
||||
const title = svgElement("text", {x:center, y:27, class:"layer-title"}); title.textContent = layer.title;
|
||||
const caption = svgElement("text", {x:center, y:47, class:"layer-caption"}); caption.textContent = layer.caption;
|
||||
svg.append(title,caption);
|
||||
});
|
||||
MAP.edges.forEach(edge => {
|
||||
const siblings = MAP.edges.filter(e => e.from === edge.from && e.to === edge.to && e.mode === edge.mode);
|
||||
const offset = (siblings.indexOf(edge) - (siblings.length - 1) / 2) * 13;
|
||||
const group = svgElement("g", {class:`edge ${edge.status === "student" ? "expected" : ""}`, "data-id":edge.id, tabindex:"0", role:"button", "aria-label":`${edge.label}: ${edge.from} → ${edge.to}`});
|
||||
const d = edgePath(edge,offset);
|
||||
group.append(svgElement("path", {d,class:"edge-line"}),svgElement("path", {d,class:"edge-hit"}));
|
||||
bindSelection(group,"edge",edge.id); svg.append(group);
|
||||
});
|
||||
MAP.nodes.forEach(node => {
|
||||
const p = positions.get(node.id);
|
||||
const g = svgElement("g", {class:`node ${node.status}`, "data-id":node.id, tabindex:"0", role:"button", "aria-label":`${node.name}: ${statusLabel[node.status]}`});
|
||||
g.append(svgElement("rect",p));
|
||||
const label = svgElement("text", {x:p.x+p.width/2,y:p.y+21}); label.textContent = node.name;
|
||||
const mark = svgElement("text", {x:p.x+p.width/2,y:p.y+38,class:"stream-mark"}); mark.textContent = {ready:"готово",student:"задание",source:"источник"}[node.status];
|
||||
g.append(label,mark); bindSelection(g,"node",node.id); svg.append(g);
|
||||
});
|
||||
}
|
||||
function bindSelection(element,type,id) {
|
||||
element.addEventListener("click", () => selectItem(type,id));
|
||||
element.addEventListener("keydown", event => {
|
||||
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); selectItem(type,id); }
|
||||
});
|
||||
}
|
||||
function walk(nodeId,direction) {
|
||||
const nodes = new Set([nodeId]), edges = new Set(), queue = [nodeId];
|
||||
while (queue.length) {
|
||||
const current = queue.shift();
|
||||
MAP.edges.filter(e => e.mode === state.view).forEach(edge => {
|
||||
if ((direction === "in" ? edge.to : edge.from) !== current) return;
|
||||
edges.add(edge.id);
|
||||
const next = direction === "in" ? edge.from : edge.to;
|
||||
if (!nodes.has(next)) { nodes.add(next); queue.push(next); }
|
||||
});
|
||||
}
|
||||
return {nodes,edges};
|
||||
}
|
||||
function updateHighlights() {
|
||||
let upstream = new Set(), downstream = new Set(), activeEdges = new Set(), activeNodes = new Set();
|
||||
if (state.selected?.type === "node") {
|
||||
const a = walk(state.selected.id,"in"), b = walk(state.selected.id,"out");
|
||||
upstream = a.nodes; downstream = b.nodes;
|
||||
activeNodes = new Set([...upstream,...downstream]); activeEdges = new Set([...a.edges,...b.edges]);
|
||||
} else if (state.selected?.type === "edge") {
|
||||
const edge = edgeById.get(state.selected.id);
|
||||
activeNodes = new Set([edge.from,edge.to]); activeEdges.add(edge.id);
|
||||
}
|
||||
svg.querySelectorAll(".node").forEach(el => {
|
||||
const id = el.dataset.id, selected = state.selected?.type === "node" && state.selected.id === id;
|
||||
el.classList.toggle("is-context-dim", Boolean(state.selected) && !activeNodes.has(id));
|
||||
el.classList.toggle("is-upstream", upstream.has(id)); el.classList.toggle("is-downstream", downstream.has(id));
|
||||
el.classList.toggle("is-selected", selected); el.setAttribute("aria-pressed",String(selected));
|
||||
});
|
||||
svg.querySelectorAll(".edge").forEach(el => {
|
||||
const edge = edgeById.get(el.dataset.id), visible = edge.mode === state.view;
|
||||
el.toggleAttribute("hidden", !visible); el.setAttribute("tabindex",visible ? "0" : "-1");
|
||||
el.setAttribute("aria-hidden",String(!visible));
|
||||
el.classList.toggle("is-context-dim",Boolean(state.selected) && !activeEdges.has(edge.id));
|
||||
el.classList.toggle("is-path",activeEdges.has(edge.id));
|
||||
const selected = state.selected?.type === "edge" && state.selected.id === edge.id;
|
||||
el.classList.toggle("is-selected", selected); el.setAttribute("aria-pressed",String(selected));
|
||||
});
|
||||
}
|
||||
function renderState(message = "") {
|
||||
document.querySelectorAll("[data-view]").forEach(b => b.setAttribute("aria-pressed",String(b.dataset.view === state.view)));
|
||||
document.getElementById("upstream-legend").textContent = state.view === "flow" ? "Синий: источники" : "Синий: связанные родители";
|
||||
document.getElementById("downstream-legend").textContent = state.view === "flow" ? "Зеленый: потребители" : "Зеленый: связанные потомки";
|
||||
document.getElementById("view-hint").textContent = state.view === "flow" ? "Стрелка: откуда загрузчик читает данные и куда записывает результат. Пунктир — ожидается по ТЗ. Календарь генерируется отдельно." : "Стрелка: от родительской строки к связанным строкам. Например, от бронирования к билетам. Это не порядок загрузки и не физические FK.";
|
||||
if (state.selected?.type === "node") renderNodeCard(nodeById.get(state.selected.id));
|
||||
else if (state.selected?.type === "edge") renderEdgeCard(edgeById.get(state.selected.id));
|
||||
else renderIntro(message);
|
||||
details.scrollTop = 0; updateHighlights();
|
||||
}
|
||||
function saveAddress() {
|
||||
const p = new URLSearchParams();
|
||||
if (state.selected) p.set(state.selected.type,state.selected.id);
|
||||
if (state.view !== "flow") p.set("view",state.view);
|
||||
const hash = p.toString();
|
||||
if (location.hash.slice(1) !== hash) location.hash = hash;
|
||||
renderState();
|
||||
}
|
||||
function selectItem(type,id) {
|
||||
const repeated = state.selected?.type === type && state.selected.id === id;
|
||||
state.selected = repeated ? null : {type,id}; saveAddress();
|
||||
}
|
||||
function readAddress() {
|
||||
const p = new URLSearchParams(location.hash.slice(1));
|
||||
state.view = p.get("view") === "keys" ? "keys" : "flow";
|
||||
state.selected = null;
|
||||
if (nodeById.has(p.get("node"))) state.selected = {type:"node",id:p.get("node")};
|
||||
else if (edgeById.has(p.get("edge"))) {
|
||||
const edge = edgeById.get(p.get("edge")); state.view = edge.mode; state.selected = {type:"edge",id:edge.id};
|
||||
}
|
||||
renderState(!state.selected && (p.has("node") || p.has("edge")) ? "Объект по ссылке не найден. Выберите таблицу на карте." : "");
|
||||
}
|
||||
function renderControls() {
|
||||
const switcher = document.getElementById("switcher");
|
||||
[["flow","Поток данных"],["keys","Связи по ключам"]].forEach(([id,label]) => {
|
||||
const b = document.createElement("button"); b.textContent = label; b.dataset.view = id;
|
||||
b.addEventListener("click", () => {
|
||||
state.view = id;
|
||||
if (state.selected?.type === "edge") state.selected = {type:"node",id:edgeById.get(state.selected.id).to};
|
||||
saveAddress();
|
||||
}); switcher.append(b);
|
||||
});
|
||||
const clear = document.createElement("button"); clear.textContent = "Общий вид"; clear.id = "clear-selection";
|
||||
clear.addEventListener("click", () => {state.selected = null; saveAddress();}); switcher.append(clear);
|
||||
}
|
||||
document.getElementById("page-title").textContent = MAP.title;
|
||||
document.getElementById("lead").textContent = MAP.lead;
|
||||
layoutMap(); drawMap(); renderControls(); readAddress();
|
||||
window.addEventListener("hashchange",readAddress);
|
||||
document.addEventListener("keydown",event => {
|
||||
if (event.key === "Escape" && state.selected) {state.selected = null; saveAddress();}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+174
-366
@@ -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<br/>Postgres bookings"] --> STG["STG<br/>сырые данные"]
|
||||
STG --> ODS["ODS<br/>очищенные данные и типы"]
|
||||
ODS --> DDS["DDS<br/>факты и измерения"]
|
||||
DDS --> DM["DM<br/>витрины для бизнес-вопросов"]
|
||||
```
|
||||
Проследите путь
|
||||
[сегмента](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<br/>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<br/>выручка и посадка"]
|
||||
FACT -->|"день × аэропорт"| TRAFFIC["dm.airport_traffic<br/>пассажиропоток"]
|
||||
FACT -->|"route_bk"| ROUTE["dm.route_performance<br/>эффективность маршрута"]
|
||||
FACT -->|"месяц × airplane_sk"| MONTHLY["dm.monthly_overview<br/>динамика по самолётам"]
|
||||
FACT -->|"passenger_sk"| LOYALTY["dm.passenger_loyalty<br/>профиль лояльности"]
|
||||
```
|
||||
| Дата вылета | Направление | Класс | 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,
|
||||
а требования к самостоятельной работе - по ТЗ.
|
||||
Reference in new issue
Block a user