# Слой DM: витрины Витрины готовят данные DDS для отчетов и дашбордов. Здесь описаны форма хранения, правила обновления и проверки. Состав колонок задан в [спецификации](../specs/2026-07-30-stand-v2-realism.md#витрины-dm) и [учебных заданиях](../labs/README.md). ## Таблица или представление Зерно — то, чему соответствует одна строка. Например, в заказах это заказ, а в дневной выручке — день и категория товара. На стенде результат с новым зерном хранится в таблице: отчет читает готовые итоги без повторного расчета. Таблицу наполняет `dm_transform`. У такой витрины есть локальная `_rep`, распределенная `_dist` и публичное представление `_v` с явным списком колонок. Для обновления по дням нужна промежуточная таблица `_stage_rep`, в которую пишут через `_stage_dist`. В нее записывают рассчитанные строки, пока основная таблица хранит прежние. Затем дневные разделы основной таблицы заменяют готовыми разделами промежуточной. Такие разделы называются партициями. Подробности — в [справочнике хранилища](storage.md#имена-объектов). Если запрос только переименовывает или дополняет поля и сохраняет одну строку на событие, достаточно обычного представления. Оно выполняет запрос при чтении, не хранит вторую копию событий и не требует пересборки. На небольших данных стенда и агрегаты могли бы вычисляться при чтении ([исследование](../research/2026-08-22-view-predicate-pushdown.md)). Готовые таблицы нужны в программе для практики загрузки, обновления и включения витрин в конвейер. ## Что готово в стенде, а что остаётся заданием Готовы три витрины дашборда и сводка качества: - `revenue_daily` — одна строка на день заказа и категорию товара; образец дневной пересборки для учебных заданий. - `purchase_vs_orders` — одна строка на заказ: сопоставление покупки из кликстрима с заказом магазина и класс расхождения. - `daily_traffic` — одна строка на день: число посетителей и известных пользователей с учетом [карты идентичностей](dds/identity.md). - `dq_summary` — одна строка на день данных и проверку качества. В [заданиях ступени 1](../labs/README.md) вы строите `events_enriched_v`, `top_pages_daily`, `session_overview`, `dq_errors_daily` и `utm_effectiveness`. Для каждой физической витрины нужно добавить сборку в `dm_transform`. Представление `events_enriched_v` отдельной задачи не требует. Стендовый дашборд читает готовые витрины и работает до выполнения заданий. Дашборд по своим витринам можно собрать в [факультативе](../labs/optional/superset-dashboard.md) после ступени 2. Устройство стендового дашборда — в [справочнике Superset](superset.md). ## Ритм пересборки Дни для пересчета выбирают по тому, как меняется источник: - События и сессии прожитого дня на стенде больше не меняются. Витрины по ним рассчитывают показатели от первого пропущенного дня до последнего прожитого и заменяют дневные партиции. - Заказы могут меняться в течение семи модельных дней. Последний слепок ODS задает диапазон дат для пересборки; пропущенные дни могут расширить его. - Новая связь в карте идентичностей позволяет определить пользователя по его прежним визитам: один `client_id` мог встречаться и до покупки, которая связала его с `user_id`. Поэтому `daily_traffic`, как и карта, пересчитывается целиком. `daily_traffic` каждый раз добавляет всю историю новой версией в `ReplacingMergeTree(_load_ts)`. Дневных партиций у нее нет, а текущие строки выбирает представление через `FINAL`. У `purchase_vs_orders` два источника. Начало диапазона — самая ранняя из трех дат: первая дата оформления заказа в последнем слепке, первый пропущенный день и первый день с `awaiting_order` — покупкой, для которой заказ пока не поступил. День ожидания проверяется отдельно: строки за него уже есть в витрине, поэтому он не считается пропущенным. Конец диапазона — более поздняя из последней даты заказов в слепке и последнего прожитого дня с покупкой. Один запуск пересобирает весь отрезок, даже если с предыдущего запуска прошло больше семи модельных дней. Для учебной сводки брака действует отдельное правило: дни выбираются по времени загрузки, а прежние итоги сохраняются после удаления источника по TTL. Оно разобрано в [задании `dq_errors_daily`](../labs/1-4-dq-errors-daily.md). ## Витрины стенда Представления `_v` явно перечисляют поля для отчетов, а также `_load_id` и `_load_ts` — идентификатор запуска и время записи. Новая колонка физической таблицы появится в представлении только после изменения его SQL. ### `dm.revenue_daily_v` Одна строка содержит итоги оплаченных заказов за день по категории товара. Запрос читает `dds.order_v` и разворачивает массивы артикулов, количеств и цен в строки позиций заказа. Артикул, количество и цена с одним индексом становятся одной строкой. Категория артикула берется из `dic.products`. `orders` считает разные заказы: несколько позиций одной категории в заказе не увеличивают этот счетчик. `units` — сумма количества товаров, `revenue` — сумма произведений цены на количество. Скидка и доставка в нее не входят. `aov` равен `revenue / orders` для того же дня и категории с округлением до двух знаков после запятой. Первый запуск рассчитывает выручку за всю доступную историю. Следующие берут диапазон дат оформления заказов из последнего слепка ODS. Если в DDS есть оплаченные заказы за день, отсутствующий в витрине, левая граница расширяется до него. Агрегат читает DDS; ODS отвечает только на вопрос об объеме. В группе `revenue_daily` дага `dm_transform` три шага: 1. [`revenue_daily_scope.sql`](../../sql/dm/revenue_daily_scope.sql) возвращает `first_day` и `last_day`. Без заказов в ODS или DDS запрос не возвращает строк, и задача завершается ошибкой. 2. [`revenue_daily_rebuild.sql`](../../sql/dm/revenue_daily_rebuild.sql) очищает `dm.revenue_daily_stage_rep`, рассчитывает показатели за выбранные дни и записывает строки через `dm.revenue_daily_stage_dist`. Вставка ждет доставки строк на шарды. 3. [`revenue_daily_replace.sql`](../../sql/dm/revenue_daily_replace.sql) заменяет в `dm.revenue_daily_rep` каждую дневную партицию `report_date` готовой партицией `dm.revenue_daily_stage_rep`. Повтор замены не добавляет копий строк. Ограничения при частичном отказе описаны в [конвейере](etl.md#идемпотентность). Основная и промежуточная таблицы распределяют строки по `cityHash64(product_category)`. На каждый день приходится не больше шести категорий стартового мира. Для такой маленькой таблицы достаточно простого ключа по категории. ### `dm.purchase_vs_orders_v` Одна строка сопоставляет события покупки с заказом магазина. События из `dds.event_v` группируются по номеру заказа `purchase_id[1]`: по формату источника у покупки один номер и одна заявленная сумма. Отдельный счет событий позволяет обнаружить повтор покупки и присвоить класс `duplicate_event`. Заказы берутся из `dds.order_v`. Полное соединение `FULL OUTER GLOBAL JOIN` сохраняет и покупки без заказа, и заказы без покупки. Обе стороны записаны подзапросами: при прямом чтении левой Distributed-таблицы каждый непарный заказ повторился бы по числу шардов ([исследование](../research/2026-08-22-distributed-join.md)). Если для строки нет пары, поля отсутствующей стороны получают `NULL` благодаря `join_use_nulls = 1`. При `join_use_nulls = 0` они заполнились бы нулями или пустыми строками, которые сложнее отличить от данных источника. Клиентская `declared_revenue` и серверная `items_total` сравниваются как `Decimal(18, 2)`. Классы проверяются по порядку: `awaiting_order` для покупки без заказа, затем `cancelled`, `lost_event`, `duplicate_event`, `amount_delta`, `match`. Поэтому отмененный заказ без события остается отмененным, а не потерянным. Партиции витрины разделены по `order_day`. Основная и промежуточная таблицы распределяют строки по `cityHash64(order_id)`. Запрос рассчитывает весь выбранный диапазон и одной вставкой записывает строки в промежуточную таблицу. Затем ее дневные разделы заменяют разделы основной таблицы. Первичная сборка охватывает всю историю обоих источников; следующие прогоны выбирают дни по правилам обоих источников, описанным выше. ### `dm.daily_traffic_v` В строке один день. `visitors` считает разные `client_id` из `dds.session_v`; `known_users` считает разные `user_id` после левого соединения с полной текущей `dds.identity_map_v`. Анонимная сессия остается в витрине. Несколько кук, связанных с одним пользователем магазина, учитываются в `visitors` по отдельности, а в `known_users` дают одного пользователя. Поэтому `known_users <= visitors`. Сессии и карта распределены по `client_id`. Однако на ClickHouse 26.3 чтение через публичные `_v` с `distributed_product_mode = 'local'` не дало локального соединения. Поэтому в запросе явно указан `GLOBAL LEFT JOIN`. Он сохраняет сессии без известного пользователя; в `known_users` учитываются только ненулевые `user_id`. Обновление всей истории новой версией описано в разделе [«Ритм пересборки»](#ритм-пересборки). ## `dm.dq_summary` Одна строка `dm.dq_summary_v` — день данных и одна проверка. Строка отвечает, каково качество дня сейчас; историю запусков хранит Airflow. Партиция совпадает с днём данных и заменяется через `REPLACE PARTITION`. Деловые колонки сводки: - `data_date` — проверяемый день; - `check_name` — имя проверяемого правила; - `reference_rows` — число деловых ключей на эталонной стороне; - `actual_rows` — число деловых ключей в проверяемом объекте; - `failed_rows` — число отсутствующих или различающихся ключей; - `status` — `pass` при нуле нарушений, иначе `fail`. В `_load_id` и `_load_ts` записаны идентификатор запуска и время расчета сводки. Даже одно расхождение дает `fail`. ## Звено DQ конвейера Даг `dq_check` стоит в `etl_pipeline` после `dm_transform` и проверяет пять правил: - `sessions_vs_reference`: ключ — `client_id` внутри дня, эталон — `uniqExact(VisitID)` из `ods.event_v`, объект — число строк `dds.session_v`; - `classes_vs_inventory`: ключ — `mismatch_class`, эталон — счётчик из `orders` в паспорте мира, объект — число заказов класса в `dm.purchase_vs_orders_v`. Проверяются `match`, `cancelled`, `lost_event`, `duplicate_event` и `amount_delta` только для покрытых паспортом закрытых дней; - `revenue_daily_vs_dds`: ключ — `product_category` внутри дня. Сравниваются `orders`, `units`, `revenue` и `aov` с прямым агрегатом `dds.order_v`; - `purchase_vs_orders_vs_dds`: ключ — `order_id` внутри дня. Все деловые поля сверки сравниваются с независимой сборкой из `dds.event_v` и `dds.order_v`; - `daily_traffic_vs_dds`: ключ — день. `visitors` и `known_users` сравниваются с прямыми агрегатами `dds.session_v` и `dds.identity_map_v`. Каждая проверка берет дни и ключи из обоих сравниваемых наборов данных. Поэтому потерянная строка остается видна на другой стороне сравнения. Расхождения считаются по ключам: завышение одного показателя не скроет занижение другого в общей сумме за день. `classes_vs_inventory` сравнивает результат с паспортом мира. Это техническая проверка доставки и обработки эталонных данных стенда. Она не проверяет сам генератор; у настоящего магазина такого эталона нет. Четыре SQL-сверки считают дневные итоги в ClickHouse и возвращают Airflow только сводку и несколько примеров расхождений. Техническая сверка с паспортом читает небольшой файл JSON в Python. Результаты всех пяти проверок записываются в общую промежуточную таблицу. Когда расчеты завершились, ее дневные разделы заменяют разделы `dm.dq_summary_rep`. Затем отдельные задачи читают сохраненную сводку и завершаются ошибкой при `failed_rows > 0`. Так расхождение сохраняется в таблице до отказа задачи. Если сам расчет не выполнен, прежняя сводка остается на месте. Задача с расхождениями выводит в журнал не больше двадцати ключей со значениями обеих сторон. Для полного списка итоговую агрегацию SQL-сверки заменяют на `SELECT * FROM comparison WHERE failed ORDER BY data_date, business_key`. Если день исчез из обоих сравниваемых наборов, новый расчет не создаст для него строк. Даг отдельно удаляет прежний результат за этот день из сводки. ## Даг самопроверки `homework_check` запускается вручную и содержит задачу для каждого из [пяти заданий по витринам](../labs/README.md). Если витрины нет или значения расходятся с источником, задача завершается ошибкой. Журнал показывает причину и примеры несовпадений. Успех означает, что проверяемые значения совпали; остальные требования задания вы проверяете сами. Ожидаемые значения рассчитываются по исходным данным текущего стенда. Для четырех заданий это события или сессии DDS, для сводки брака — таблицы ошибок ODS. Сравнение по деловому ключу учитывает отсутствующие строки с обеих сторон. Результаты не пишутся в `dq_summary`: самопроверка учебных витрин отделена от качества готового конвейера. Готовые SQL-проверки открывайте после собственного решения: они содержат расчет существенной части задания. ## Отклонённые варианты - **Весь слой представлениями.** Дёшево и всегда свежо, но главный производственный паттерн слоя — пересборка агрегатов — остался бы невидим, а `dm_transform` — без работы. - **Материализовать всё.** Копия событий ради переименования колонок — лишняя; такой пример приучал бы без нужды дублировать исходные данные. - **Гибрид по цене запроса.** Выбор формы по стоимости, которой на объёмах стенда нет: сторож без настоящей опасности. - **Проверки домашек в конвейерном звене DQ.** Конвейер стоял бы красным до выполнения лаб, а проверка, которая всегда красная, приучает себя игнорировать. - **«Жёлтый» статус недостроенной витрины.** У задачи Airflow нет такого состояния; изображать его логами — семантика, которую пришлось бы расшифровывать. Красное «ещё не построено» честнее. - **Самопроверка пишет в `dq_summary`.** Результаты учебных попыток смешались бы с историей качества конвейера. - **Окно заказов для `daily_traffic` — прошлое замерзает.** Дни за окном хранили бы число известных на момент закрытия — витрина тихо расходится с картой и протаскивает обратно «знание на дату», отвергнутое развилкой карты (#133). - **Инкрементальный пересчёт затронутых дней по `first_matched_at`.** Производственный ход, но на объёмах стенда его экономия неизмерима, а след новых связок требует вернуть колонку, выкинутую в #133. Стал лабой повышенной сложности — issue #149. - **Superset поверх DDS без слоя DM.** Терялся бы урок «витрина — контракт под потребителя», против раздела 7 спеки. ## Связанные решения - [Мастер-спека](../specs/2026-07-30-stand-v2-realism.md), разделы 4 и 7, — состав колонок витрин и классы сверки. - [Дневной конвейер](etl.md) — запуск `dm_transform`, объём прогона, замена партиций на кластере. - [Дока хранилища](storage.md) — имена объектов, конвенция чтения через `_v`, партиция-донор. - [Карта модели DDS](dds/README.md) — источники витрин. - Резолюция — тикет #135 карты #127.