docs(map): добавлены карта и руководство по структуре DWH

Объяснены зерно таблиц, связи и путь бронирования до витрины.
Добавлена автономная карта с двумя видами связей и адресами объектов.
Состояние заглушек сверено с DDL и ТЗ; SQL и DAG не изменялись.

Проверки: сборка и ссылки, make lint, pytest (4 passed, 14 skipped),
Chromium file:// offline: выбор, связи, сброс, история и узкий экран.

Refs #2
This commit is contained in:
ddadmin committed 2026-09-20 13:01:57 -04:00
1 parent 77d72b08c6
commit b9bcfe7230
9 files changed
+10806 -373

No files matched your search

+160
View File
@@ -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())