diff --git a/.env.example b/.env.example
index 6469fa9..718dbec 100644
--- a/.env.example
+++ b/.env.example
@@ -10,6 +10,7 @@ CLICKHOUSE_ETL_PASSWORD=etl-26-writer
CLICKHOUSE_LIFECYCLE_PASSWORD=lifecycle-26-owner
CLICKHOUSE_BI_PASSWORD=bi-26-reader
CLICKHOUSE_ANALYST_PASSWORD=analyst-26-reader
+CLICKHOUSE_STUDENT_PASSWORD=student-26-lab
CLICKHOUSE_GRAFANA_PASSWORD=grafana-26-reader
KAFKA_EXTERNAL_PORT=29092
PROMETHEUS_PORT=29090
diff --git a/AGENTS.md b/AGENTS.md
index 594c75e..d8621b2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -46,15 +46,10 @@
## Кому что поручать
-Читатель здесь не побочный потребитель, а тот, ради кого стенд существует:
-код, комментарии и документы и есть продукт. Поэтому всё, что человек будет
-читать и разбирать, пишет модель с чувством меры — сейчас это Opus. Codex
-берёт техническую работу без вкусовых решений: миграции, разбор журналов и
-объёмного вывода, обход репозитория, повторяющиеся правки. Там его запас
-лимитов работает на нас, а экономить их на читаемом коде — экономия ложная:
-переписывать выйдет дороже, чем сразу написать хорошо.
+Автор задачи сам пишет и редактирует код, комментарии и документы, если
+владелец не задал другое распределение работы.
-Делить работу между ними стоит не целыми задачами, а подзадачами, и решать
+Делить работу стоит не целыми задачами, а подзадачами, и решать
это при планировании. Вкусовая часть идёт первой: она задаёт форму, имена и
границы, под которые механическая потом подстраивается.
diff --git a/README.md b/README.md
index 1cf9769..38cac01 100644
--- a/README.md
+++ b/README.md
@@ -121,6 +121,9 @@ Kafka, затем заново создаст схему хранилища и
Для подключения ко второму узлу укажите порт `28124`.
Пользователь `analyst` может читать данные во всех слоях и справочниках.
+Для заданий с собственными таблицами наблюдений используйте `student` и пароль
+из `CLICKHOUSE_STUDENT_PASSWORD` в `.env`. Он читает те же источники, а создавать,
+заполнять и удалять таблицы может только в базе `default`.
Для создания учебных витрин используйте подключение, описанное
в [инструкции к заданиям](docs/labs/README.md#как-применить-свой-ddl).
Нативные порты `29000` и `29001` нужны `clickhouse-client`, но не DBeaver.
diff --git a/compose.yaml b/compose.yaml
index df87088..16b2a3e 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -20,6 +20,7 @@ x-clickhouse-common: &clickhouse-common
CLICKHOUSE_LIFECYCLE_PASSWORD: ${CLICKHOUSE_LIFECYCLE_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_BI_PASSWORD: ${CLICKHOUSE_BI_PASSWORD:?Скопируйте .env.example в .env}
CLICKHOUSE_ANALYST_PASSWORD: ${CLICKHOUSE_ANALYST_PASSWORD:?Скопируйте .env.example в .env}
+ CLICKHOUSE_STUDENT_PASSWORD: ${CLICKHOUSE_STUDENT_PASSWORD:?Добавьте CLICKHOUSE_STUDENT_PASSWORD из .env.example в .env}
CLICKHOUSE_GRAFANA_PASSWORD: ${CLICKHOUSE_GRAFANA_PASSWORD:?Скопируйте .env.example в .env}
depends_on:
clickhouse-keeper:
diff --git a/docs/adr/0007-clickhouse-access.md b/docs/adr/0007-clickhouse-access.md
index e322580..a027322 100644
--- a/docs/adr/0007-clickhouse-access.md
+++ b/docs/adr/0007-clickhouse-access.md
@@ -3,10 +3,11 @@
Дата: 9 августа 2026 года. Статус: принято; роль `monitoring_reader`
и пользователь `grafana` добавлены 21 августа 2026 года (#114), права
`bi_reader` расширены до ODS, DDS и DM 25 августа 2026 года (#150).
+Пользователь `student` и роль `student_writer` добавлены 22 сентября 2026 года.
## Решение
-У кластера семь пользователей и шесть ролей.
+У кластера восемь пользователей и семь ролей.
- `default` — с паролем, только служебный: проверки здоровья нод и работа
изнутри контейнеров. Приложения им не ходят.
@@ -20,6 +21,10 @@
шире.
- `analyst` с ролью `analyst_reader` — чтение всех слоёв и справочников. Им
человек подключается снаружи, из своего клиента.
+- `student` с ролями `analyst_reader` и `student_writer` — то же чтение и
+ работа со своими таблицами в `default`: создание, вставка и удаление.
+ Сохранение учебных наблюдений не требует прав технической учётной записи
+ `etl` на изменение слоёв. Таблицы локальные; работа ведётся на одной ноде.
- `grafana` с ролью `monitoring_reader` — чтение слоёв, таблиц базы `dic` и
системных таблиц, `CLUSTER` и `REMOTE`. Им ходит Grafana, и больше никто.
- `dict` с ролью `dict_reader` — чтение одной базы `dic`, пароля нет. Им
@@ -148,6 +153,11 @@ DDL для матвью приёма выполняется от имени `lif
## Что проверено
+Права `student` сверены 22 сентября 2026 года через MCP Context7 и на стенде
+26.3.17.56: создание локальных `MergeTree`, `INSERT SELECT` из DDS и DM,
+чтение и удаление своих таблиц работают. На обеих нодах вставка в STG, ODS,
+DDS, DM и `dic` завершается `ACCESS_DENIED`.
+
Сверено 9 августа 2026 года по документации ClickHouse через MCP Context7 и
на живом стенде 26.3.17.56.
diff --git a/docs/architecture/stand.md b/docs/architecture/stand.md
index ada8bff..e1637ce 100644
--- a/docs/architecture/stand.md
+++ b/docs/architecture/stand.md
@@ -22,7 +22,7 @@
## Пользователи и роли ClickHouse
-В ClickHouse семь пользователей и шесть ролей:
+В ClickHouse восемь пользователей и семь ролей:
- `etl` — учётная запись рабочих дагов Airflow; роль `etl_writer` читает и
пишет слои хранилища;
@@ -33,6 +33,9 @@
справочники `dic`, а запросы Superset обращаются к публичным `_v`;
- `analyst` — учётная запись аналитика с правом чтения всех слоёв и
справочников;
+- `student` — учебная учётная запись: роль `analyst_reader` дает чтение
+ источников, а `student_writer` — создание, заполнение и удаление своих
+ таблиц в `default`. Записи в слои хранилища у нее нет;
- `grafana` — учётная запись Grafana; роль `monitoring_reader` читает все слои,
системные таблицы и данные со всех реплик кластера;
- `dict` — беспарольная учётная запись словаря товаров с правом чтения базы
@@ -49,6 +52,9 @@ Superset получает пароль `bi`, а Grafana — пароль `grafan
## Применение настроек
+Для существующего стенда сначала добавьте `CLICKHOUSE_STUDENT_PASSWORD`
+из `.env.example` в свой `.env`.
+
Чтобы применить новые значения ClickHouse из `.env` или изменения файлов в
`infra/clickhouse/config.d/` и `infra/clickhouse/users.d/`, пересоздайте ноды:
diff --git a/docs/formats/clickstream-event.md b/docs/formats/clickstream-event.md
index 0f9028e..bdeeb42 100644
--- a/docs/formats/clickstream-event.md
+++ b/docs/formats/clickstream-event.md
@@ -98,7 +98,7 @@ DDS складывает свою модель и называет атрибу
| 41 | `productID` | `Array(String)` | `object` | `product_id` | id товаров события |
| 42 | `productName` | `Array(String)` | `object` | `product_name` | названия тех же товаров |
| 43 | `productCategory` | `Array(String)` | `object` | `product_category` | категории тех же товаров |
-| 44 | `productPrice` | `Array(Int64)` | `int64` | `product_price` | цена за штуку целым числом: деньги генератор считает целыми |
+| 44 | `productPrice` | `Array(Int64)` | `int64` | `product_price` | цена за штуку, округленная до целых рублей |
| 45 | `productQuantity` | `Array(UInt64)` | `uint64` | `product_quantity` | количество штук каждого товара |
| 46 | `productEventType` | `Array(String)` | `object` | `product_event_type` | действие с товаром: стенд шлёт add и purchase, полный словарь Метрики (detail, remove, impressions) не берём |
| 47 | `ecommerce` | `String` | `object` | `ecommerce` | сырой JSON события, как отдаёт Метрика — материал лабы про разбор JSON внутри колонки |
diff --git a/docs/labs/README.md b/docs/labs/README.md
index cf593f1..59a2ff7 100644
--- a/docs/labs/README.md
+++ b/docs/labs/README.md
@@ -124,8 +124,19 @@ DM](../architecture/dm.md).
### Почему выручка не сходится
-Открывается после ступени 1. Расхождения между источниками, повторы
-покупок и день, после которого отчет можно считать окончательным.
+Открывается после ступени 1. Четыре расследования качества данных:
+
+- [Куда пропали покупки](optional/missing-purchases.md): найти заказы без
+ события, выбрать источник витрины и условие проверки полноты.
+- [Почему повтор покупки завышает выручку](optional/duplicate-purchases.md):
+ выбрать зерно и ключ расчета, проверить его на повторных событиях.
+- [Какая позиция объясняет разницу в сумме](optional/order-amount-difference.md):
+ разобрать позиции без потери точности и локализовать расхождение источников.
+- [Почему вчерашняя выручка меняется сегодня](optional/revenue-over-time.md):
+ обосновать окно пересчета витрины и требования к истории заказов.
+
+Первые три работы используют готовые данные. Четвертая продвигает мир через
+Airflow; для нее вы сохраняете состояния заказов после каждого модельного дня.
### Тонкий анализ данных
diff --git a/docs/labs/optional/duplicate-purchases.md b/docs/labs/optional/duplicate-purchases.md
new file mode 100644
index 0000000..09686ce
--- /dev/null
+++ b/docs/labs/optional/duplicate-purchases.md
@@ -0,0 +1,82 @@
+# Почему повтор покупки завышает выручку
+
+**Чему учит:** выбрать зерно и ключ расчета, чтобы повторные события
+не умножали денежную сумму.
+
+Факультатив доступен после ступени 1. Работайте в DBeaver пользователем
+`analyst` по [инструкции](../../../README.md#подключиться-из-dbeaver).
+Дождитесь завершения `etl_pipeline`; оставьте `world_live` на паузе и
+дождитесь уже начатых прогонов, чтобы данные не менялись между запросами.
+
+## Проверьте расчет по событиям
+
+В ODS уже устранены повторные доставки событий. Достаточно ли этого,
+чтобы сложить суммы всех `purchase` для витрины заказов? Проверьте на данных.
+
+Возьмите один закрытый день: его дата должна быть не позднее
+`max(snapshot_date) - 6` из `ods.order_v`. Во всех запросах к `dds.event_v`
+отбирайте этот `event_date` и `event_type = 'purchase'`.
+
+Номер заказа находится в `purchase_id[1]`, заявленная сумма — в
+`purchase_revenue[1]`. По формату источника у события покупки один номер заказа.
+
+Сначала посчитайте число событий и сумму всех заявленных сумм. Затем найдите
+номера заказов, которые встречаются несколько раз. Если таких нет, выберите
+другой закрытый день.
+
+Для двух повторившихся покупок выпишите `event_id`, `client_id`, `event_time`,
+номер заказа и сумму. Сопоставьте число событий и заказов в `dds.order_v`.
+
+## Соберите расчет по заказам
+
+Напишите запрос с одной строкой на номер заказа. Сохраните в результате
+номер, число исходных событий и одну заявленную сумму. До выбора суммы
+проверьте, что во всех событиях заказа она одинакова. Затем посчитайте итог
+дня по результату этого запроса и сравните с расчетом по событиям.
+
+Проверьте, что номера в результате уникальны, а число строк совпадает с числом
+разных номеров в исходных событиях. Отдельно проверьте свое решение на случае
+двух разных заказов с одинаковой суммой: сколько денег оно учтет?
+
+## Найдите заказы, из-за которых сумма завышена
+
+Возьмите один повторившийся номер заказа. Сколько денег он добавил в первый
+расчет и сколько — во второй? Подтвердите разность его событиями.
+
+Повторите расчет для остальных повторившихся номеров. Сохраните таблицу:
+номер заказа, число событий и вклад в разность сумм. Проверьте, объясняют ли
+эти заказы все расхождение за день до копейки.
+
+Слой ODS убирает повторную доставку одного события по его идентификатору;
+`dds.event_v` читает уже это состояние. Сравните число разных `event_id`
+и число разных номеров заказа в выбранных строках. Почему устранение
+повторов по первому ключу не дало тот же результат, что по второму?
+Подтвердите ответ конкретными идентификаторами.
+
+После собственного расчета найдите эти заказы в `dm.purchase_vs_orders_v`.
+Если у повторившегося заказа класс отличается от `duplicate_event`, проверьте
+его статус и [порядок присвоения классов](../../architecture/dm.md#dmpurchase_vs_orders_v).
+Класс витрины не заменяет поиск всех повторов в событиях.
+
+## Определите границы расчета
+
+Сложите `revenue` из `dm.revenue_daily_v` за тот же `report_date` по всем
+категориям. Эта витрина считает стоимость позиций оплаченных заказов,
+без скидки и доставки.
+
+Сравните результат с исправленной клиентской суммой. Совпадение не требуется:
+удаление повторов устраняет одну причину расхождения. Найдите хотя бы один
+заказ, который объясняет, почему деньги трекера и магазина все еще могут
+различаться. Для этого сопоставьте события, заказ и готовую сверку.
+
+Рядом со своим запросом укажите его зерно и ключ. Обоснуйте, где нужна одна
+строка на событие, а где — одна на заказ: следует ли ради этого расчета
+удалять повторные покупки из `dds.event_v`? Используйте найденные `event_id`.
+
+## Что должно получиться
+
+- [ ] сохранен запрос с одной строкой на заказ, числом событий и суммой;
+- [ ] уникальность ключа и полнота набора номеров проверены по источнику;
+- [ ] разность сумм полностью объяснена вкладом лишних событий;
+- [ ] обоснованы ключ расчета и место устранения повторов;
+- [ ] найден случай, показывающий границы расчета денег по трекеру.
diff --git a/docs/labs/optional/missing-purchases.md b/docs/labs/optional/missing-purchases.md
new file mode 100644
index 0000000..ff6333a
--- /dev/null
+++ b/docs/labs/optional/missing-purchases.md
@@ -0,0 +1,102 @@
+# Куда пропали покупки
+
+**Чему учит:** проверить полноту данных по независимому источнику и выбрать
+источник для витрины заказов.
+
+Факультатив доступен после ступени 1. В DBeaver подключитесь пользователем
+`analyst` по [инструкции](../../../README.md#подключиться-из-dbeaver).
+Дождитесь завершения `etl_pipeline`; на время расследования оставьте
+`world_live` на паузе и дождитесь уже начатых прогонов.
+
+## Выберите день
+
+Можно ли построить полную витрину заказов по событиям `purchase`?
+Проверьте это на одном дне: сначала сопоставьте события сайта, затем сверьте
+номера заказов с выгрузкой магазина.
+
+Возьмите день, для которого уже получен последний слепок семидневного окна.
+Запрос показывает самую позднюю подходящую дату:
+
+```sql
+SELECT max(snapshot_date) - 6 AS last_closed_day
+FROM ods.order_v;
+```
+
+Используйте выбранный день во всех запросах. События отбирайте по
+`dds.event_v.event_date`, заказы — по `dds.order_v.order_date`.
+Сравнивайте все заказы дня, включая отмененные: сейчас вопрос в том,
+зарегистрировал ли магазин заказ, а не в том, был ли он оплачен.
+
+## Найдите разрыв в событиях
+
+При оформлении заказа посетитель видит страницу подтверждения, а трекер
+отправляет событие покупки. Просмотр страницы и покупка — отдельные события.
+
+Посчитайте просмотры страницы подтверждения и события покупки:
+
+- у просмотра `event_type = 'pageview'`, а `url` заканчивается на `/confirmation`;
+- у покупки `event_type = 'purchase'`.
+
+Вычтите число событий покупки из числа просмотров подтверждения. Сохраните
+оба счетчика и разность. Как соотносились бы эти числа, если бы для каждого
+заказа пришло ровно по одному событию каждого вида? Что можно предположить
+по вашему результату и чего эти два числа еще не доказывают?
+
+## Сверьте с магазином
+
+В событии номер заказа находится в `purchase_id[1]`, в заказах — в `order_id`.
+Соберите список заказов выбранного дня, для которых нет события покупки.
+Несколько событий с одним номером заказа должны оставаться одной покупкой.
+Сохраните запрос сверки: на каждый такой заказ он должен возвращать дату,
+номер и статус. Дату задавайте в одном месте, чтобы запрос можно было
+повторить для другого дня.
+
+Проверьте два равенства:
+
+- число заказов магазина равно числу разных заказов из событий плюс число
+ заказов без события;
+- разность просмотров подтверждения и событий покупки равна числу заказов
+ без события.
+
+Первое равенство проверяет полноту сопоставления источников. Второе связывает
+разность счетчиков с найденными заказами без события. Проверьте, как на него
+влияют повторные покупки.
+
+Найдите номер заказа с несколькими событиями `purchase`. Для его `client_id`
+выведите события рядом с этими покупками по времени: `event_time`,
+`event_type` и `url`. Найдите просмотры `/confirmation` перед первой
+и повторной покупкой. Посчитайте, сколько повтор добавил просмотров
+подтверждения и сколько — покупок. Изменилась ли от этих добавлений разность
+счетчиков? Подтвердите ответ найденными строками. Второе равенство относится
+к устройству этого стенда; для другого источника его нужно проверять заново.
+
+Если в выбранном дне повторов нет, возьмите другой закрытый день.
+
+## Сопоставьте вывод с готовой сверкой
+
+Найдите свои заказы без события в `dm.purchase_vs_orders_v` по `order_id`.
+Сравните их `status` и `mismatch_class`. Витрина присваивает заказу один класс,
+хотя причин расхождения может быть несколько. По
+[правилу классификации](../../architecture/dm.md#dmpurchase_vs_orders_v)
+объясните, почему потеря события не всегда попадает в `lost_event`.
+
+Возьмите один заказ из своего списка и сохраните два запроса: первый находит
+его в магазине, второй показывает отсутствие покупки в трекере.
+
+## Выберите источник и условие проверки
+
+По результатам сверки укажите источник для витрины, в которой должен быть
+каждый зарегистрированный заказ. Обоснуйте выбор найденными номерами.
+
+Рядом с запросом запишите, когда его результат можно считать окончательным.
+Что изменится, если запустить его до получения всех слепков выбранного дня?
+Позволяет ли отсутствие события в `dds.event_v` установить место потери —
+до приема в хранилище или при обработке? Какие данные нужны, чтобы это выяснить?
+
+## Что должно получиться
+
+- [ ] сохранен запрос сверки по дате, возвращающий заказы без события;
+- [ ] оба равенства сходятся, влияние повторного просмотра объяснено;
+- [ ] один случай подтвержден запросами к обоим источникам;
+- [ ] источник витрины выбран по результатам сверки;
+- [ ] указаны условие окончательной проверки и границы ее выводов.
diff --git a/docs/labs/optional/order-amount-difference.md b/docs/labs/optional/order-amount-difference.md
new file mode 100644
index 0000000..0f18a45
--- /dev/null
+++ b/docs/labs/optional/order-amount-difference.md
@@ -0,0 +1,84 @@
+# Какая позиция объясняет разницу в сумме
+
+**Чему учит:** разобрать вложенные данные без потери точности и локализовать
+расхождение двух источников до товарной позиции.
+
+Факультатив доступен после ступени 1. В DBeaver используйте пользователя
+`analyst` из [инструкции подключения](../../../README.md#подключиться-из-dbeaver).
+Дождитесь завершения `etl_pipeline`; оставьте `world_live` на паузе и
+дождитесь уже начатых прогонов.
+
+## Найдите заказ
+
+Суммы покупки в трекере и заказа в магазине различаются. Разберите их позиции
+и проверьте отдельно точность своего запроса и различия между событием и заказом.
+
+В `dm.purchase_vs_orders_v` выберите один заказ с
+`mismatch_class = 'amount_delta'`. Возьмите закрытый день: `order_day`
+не позднее `max(snapshot_date) - 6` из `ods.order_v`. Сохраните `order_id`,
+`order_day`, `declared_revenue` и `items_total`.
+
+Найдите этот заказ в `dds.order_v`. Сопоставьте `items_total`, `discount`,
+`delivery` и `total`. Запишите равенство, связывающее эти четыре значения,
+и проверьте его на заказе. С какой суммой магазина следует сравнивать
+`declared_revenue`, если мы ищем расхождение именно в составе корзины?
+Проверьте свой выбор после разбора позиций.
+
+## Разберите позиции с двух сторон
+
+Найдите событие покупки в `ods.event_v`: `purchaseID[1]` совпадает с номером
+заказа, `EventDate` — с выбранным днем, `EventType = 'purchase'`.
+Откройте поле `ecommerce`. Внутри JSON массив `purchase.products` содержит
+позиции клиента с полями `id`, `price` и `quantity`.
+
+Разверните этот массив в строки и получите артикул, точную цену, количество
+и стоимость каждой позиции. Для разбора пригодятся `JSONExtractArrayRaw`
+и `JSONExtract`, для разворачивания массива — `ARRAY JOIN`.
+Деньги извлекайте как `Decimal` с двумя знаками после запятой.
+
+С другой стороны используйте массивы `item_sku`, `item_price` и
+`item_quantity` заказа в `dds.order_v`. Элементы с одним индексом относятся
+к одной позиции; при разворачивании сохраняйте эту связь.
+
+Сопоставьте позиции по артикулу так, чтобы остались видны товары,
+присутствующие только с одной стороны. Для каждой позиции сравните наличие,
+цену и количество. Что различается и сколько это добавляет к расхождению
+сумм?
+Сохраните запрос сверки для выбранных даты и номера заказа; в результате
+оставьте артикул, значения с обеих сторон и разность стоимостей позиций.
+
+## Проверьте объяснение
+
+Подтвердите три равенства:
+
+- сумма стоимостей клиентских позиций равна `declared_revenue`;
+- сумма стоимостей позиций магазина равна `items_total`;
+- сумма расхождений по позициям равна
+ `declared_revenue - items_total`.
+
+Затем сравните точные цены из JSON с готовым массивом `productPrice`
+события. Пересчитайте стоимость клиентской корзины по этому массиву:
+сойдется ли она с `declared_revenue` до копейки? Найдите цены, на которых
+результаты различаются, и сверьте наблюдение с
+[форматом выгрузки](../../formats/clickstream-event.md). Если все цены
+выбранной корзины целые, возьмите еще один заказ с расхождением и дробной ценой.
+
+Какие строки подтверждают найденное расхождение? Есть ли в источниках
+сведения о том, почему оно возникло? Отделите наблюдения от предположений.
+
+## Выберите поля для точного расчета
+
+Укажите рядом с запросом, откуда брать цену, каким типом ее читать и по какому
+ключу сопоставлять позиции. Подтвердите выбор результатами обеих сверок:
+суммы разобранных позиций с суммой своего источника и позиций двух источников
+между собой. Какой результат укажет на ошибку вашего разбора, а какой
+потребует искать причину выше — в данных, которые получил запрос?
+
+## Что должно получиться
+
+- [ ] сохранен запрос сверки позиций, не теряющий товары без пары;
+- [ ] все три равенства сходятся до копейки;
+- [ ] найден пример потери точности в готовом массиве цен;
+- [ ] выбор полей, типа денег и ключа сопоставления подтвержден расчетами;
+- [ ] расхождение источников сверки отделено от ошибки своего разбора и различия
+ между стоимостью корзины и итогом со скидкой и доставкой.
diff --git a/docs/labs/optional/revenue-over-time.md b/docs/labs/optional/revenue-over-time.md
new file mode 100644
index 0000000..469236e
--- /dev/null
+++ b/docs/labs/optional/revenue-over-time.md
@@ -0,0 +1,188 @@
+# Почему вчерашняя выручка меняется сегодня
+
+**Чему учит:** обосновать окно пересчета витрины и требования к истории
+заказов по наблюдаемым опозданиям и изменениям статусов.
+
+Факультатив доступен после ступени 1. Понадобятся Airflow и DBeaver с
+подключением `student` по [инструкции](../../../README.md#подключиться-из-dbeaver).
+Он читает данные стенда и записывает ваши наблюдения в базу `default`.
+Вы будете наблюдать один день оформления заказов, продвигая мир по одному
+модельному дню. Проверьте, можно ли при загрузке выручки каждый раз считать
+только новый день. Новые данные останутся в стенде.
+
+## Подготовьте первое наблюдение
+
+Поставьте `world_live` на паузу. Дождитесь завершения начатых прогонов
+генерации и обработки, затем запустите `etl_pipeline` и дождитесь успеха.
+Так исходное наблюдение будет снято после обновления витрин.
+
+Узнайте последний день событий и дату последнего полученного слепка:
+
+```sql
+SELECT
+ (SELECT max(event_date) FROM dds.event_v) AS event_day,
+ (SELECT max(snapshot_date) FROM ods.order_v) AS snapshot_day;
+```
+
+День `event_day` обозначим **D**. Наблюдайте именно его до конца работы.
+Дата слепка, по которому сделано очередное наблюдение, будет **S**.
+Это модельные даты магазина. Календарная дата запуска Airflow день D не задает.
+
+Слепок снимается к концу суток и доставляется следующим прогоном. Поэтому
+покупки последнего сыгранного дня уже видны в событиях, а их заказов в
+хранилище еще нет. Проверьте это для D в `dds.order_v` и
+`dm.purchase_vs_orders_v`.
+
+## Подготовьте место для наблюдений
+
+Публичное представление заказа показывает текущее состояние, а сырые слепки
+хранятся ограниченное время. Сохраните историю в двух своих таблицах: потом
+вы сможете сравнивать наблюдения обычными SQL-запросами в том же DBeaver.
+
+Выполните DDL ниже. Это локальные таблицы на узле вашего подключения;
+всю работу выполняйте через одно подключение. Если имена уже заняты вашей
+прошлой работой, выберите другие и используйте их в дальнейших запросах.
+
+```sql
+CREATE TABLE default.lab_order_history
+(
+ order_date Date,
+ snapshot_date Date,
+ order_id String,
+ status String,
+ items_total Decimal(18, 2)
+)
+ENGINE = MergeTree
+ORDER BY (order_date, snapshot_date, order_id);
+
+CREATE TABLE default.lab_revenue_history
+(
+ order_date Date,
+ snapshot_date Date,
+ revenue Decimal(18, 2),
+ awaiting_order_ids Array(String)
+)
+ENGINE = MergeTree
+ORDER BY (order_date, snapshot_date);
+```
+
+## Сохраните первое наблюдение
+
+Напишите два `INSERT INTO ... SELECT` с явным списком колонок назначения:
+
+| Куда записать | Что взять из стенда |
+|---|---|
+| `default.lab_order_history` | По строке на заказ дня D из `dds.order_v`: `order_date`, `order_id`, `status`, `items_total`; добавьте дату наблюдения S |
+| `default.lab_revenue_history` | Одну строку с D, S, суммой `revenue` за `report_date = D` из `dm.revenue_daily_v` и массивом номеров `awaiting_order` за `order_day = D` из `dm.purchase_vs_orders_v` |
+
+Во втором запросе сумму и массив получите отдельными агрегатами, чтобы
+заказы не умножили строки выручки при соединении. Для массива пригодится
+`groupArray(order_id)`. Дату S берите из `max(snapshot_date)` в `ods.order_v`.
+
+Сначала выполните части `SELECT` отдельно и проверьте результаты, затем
+сохраните их. До первого слепка заказов дня D еще нет: в таблицу заказов
+строки не добавятся, но в таблице итогов должна появиться строка с нулевой
+выручкой. Она зафиксирует, что наблюдение сделано.
+
+Каждую пару D и S сохраняйте один раз: повторный `INSERT` добавит копии строк.
+После записи проверьте, что на эту пару приходится одна строка итогов,
+а число заказов и число разных `order_id` совпадают с `dds.order_v` за D.
+
+Выручка этой витрины — стоимость позиций оплаченных заказов без скидки и
+доставки. Для каждого сохраненного наблюдения посчитайте число заказов по
+статусам и сумму `items_total` только у `paid`. Эта сумма должна сходиться
+с сохраненной выручкой, сложенной по всем категориям.
+
+## Продвигайте мир по одному дню
+
+Для каждого следующего наблюдения:
+
+1. Запустите `world_next_day` с `days = 1` и дождитесь успеха.
+2. Запустите `etl_pipeline` и дождитесь успеха: `world_next_day` сам витрины
+ не обновляет.
+3. Повторите два запроса сохранения с новой датой S и проверьте записанные строки.
+
+Во всех запросах остается прежний день D. За один запуск проигрывайте один
+день, чтобы сохранить промежуточные состояния. Пакетный `world_next_day`
+отправляет события без пауз; ждать 24 минуты живого дня здесь не нужно.
+
+Продолжайте до слепка **D + 7** включительно. У заказов дня D окно состоит
+из семи дней, от D до D + 6. Последний шаг нужен для сравнения состояния
+в конце окна и за его пределами.
+От исходного наблюдения потребуется восемь однодневных запусков.
+
+## Найдите изменения в уже загруженном дне
+
+Для сравнения прочитайте `default.lab_order_history` дважды: в одном
+подзапросе отберите прежнюю S, в другом — новую; день D остается общим.
+Соедините состояния по `order_id`, сохранив и заказы без пары. Итоги для этих
+же D и S возьмите из `default.lab_revenue_history`. Найдите:
+
+- заказы, отсутствовавшие в первом слепке D, но появившиеся в следующем;
+- переходы из `created` в `paid`;
+- отмены заказов, для которых раньше был виден статус `paid`.
+
+Первый слепок, полученный на следующий день, — обычная доставка заказов.
+Опоздавшими здесь считаются те, которых не было уже в этом слепке.
+Покажите на нескольких номерах, как они исчезают из `awaiting_order`.
+
+Выберите два соседних наблюдения, между которыми хотя бы один заказ перешел
+из `paid` в `cancelled`. Разложите изменение выручки по заказам.
+Составьте таблицу: номер заказа, прежний и новый статусы, вклад в выручку
+до и после изменения. Вклад определите по правилу расчета витрины, указанному
+выше; учтите и заказы, которых еще не было в первом наблюдении. Сумма
+изменений вкладов должна совпасть с изменением `revenue` до копейки.
+
+Покажите на этих наблюдениях, какой результат остался бы в витрине,
+если бы конвейер после первого расчета обрабатывал только новые дни заказов.
+Достаточно ли добавлять в старый день только опоздавшие заказы? Проверьте
+свой ответ на найденных изменениях статусов.
+
+## Определите, какую историю нужно хранить
+
+Возьмите отмененный заказ, у которого в вашей истории был статус `paid`.
+Укажите два наблюдения, доказывающих, что он был оплачен, а затем отменен.
+
+Теперь возьмите отмененный заказ, для которого ни в одном вашем наблюдении
+нет `paid`. Проверьте утверждение «этот заказ никогда не был оплачен»:
+что известно о его состоянии между двумя слепками? Достаточно ли сохраненных
+наблюдений для такого вывода?
+
+Сформулируйте требования к данным для двух задач: восстановить состояние
+витрины на момент прошлого расчета и узнать, был ли заказ когда-либо оплачен.
+Какие сведения для каждой задачи должен передавать источник и сохранять
+хранилище? Проверьте, каких нужных сведений нет в ваших таблицах наблюдений.
+
+## Задайте правило пересчета
+
+Сравните сохраненные состояния при S = D + 6 и S = D + 7: состав заказов,
+их статусы, суммы и выручку. Какой слепок последним мог изменить заказы дня D
+и каким прогоном он доставлен? Укажите наблюдение, после которого отчет
+можно считать окончательным. Было бы достаточно совпадения двух соседних
+сумм, если бы окно еще не закончилось?
+
+Запишите правило выбора дней для пересчета после очередного слепка.
+Считайте, что начальная история уже загружена и ежедневные прогоны не
+пропускались. Укажите, по какой дате определяются границы, какие дни нужно
+пересчитать и когда день можно перестать включать в расчет. Подтвердите
+правило своими наблюдениями, включая опоздание и отмену.
+
+После собственных выводов сверьтесь с описанием
+[слепка и его доставки](../../architecture/orders/snapshot.md#слепок-и-его-доставка)
+и [границ истории отмен](../../architecture/orders/fate.md#судьба-заказа).
+
+## Что должно получиться
+
+- [ ] сохранена последовательность наблюдений одного дня D с датами слепков S;
+- [ ] на опоздавших заказах и изменениях статусов показаны ошибки загрузки
+ только новых дней или только новых заказов;
+- [ ] изменение выручки между наблюдениями сходится с изменением вкладов заказов;
+- [ ] оплата одной из последующих отмен подтверждена двумя наблюдениями;
+- [ ] сформулированы требования к истории расчетов и истории оплаты;
+- [ ] правило пересчета и момент закрытия дня обоснованы наблюдениями
+ и правилом доставки слепков.
+
+После работы оставьте `world_live` на паузе либо верните автоматическое
+продвижение, если пользовались им до наблюдения. Таблицы истории останутся
+для повторной проверки выводов. Когда они больше не нужны, удалите только
+свои `default.lab_order_history` и `default.lab_revenue_history`.
diff --git a/docs/specs/2026-09-01-labs-program.md b/docs/specs/2026-09-01-labs-program.md
index 87f2627..369e665 100644
--- a/docs/specs/2026-09-01-labs-program.md
+++ b/docs/specs/2026-09-01-labs-program.md
@@ -149,28 +149,59 @@
## 4. Факультативы — пять разделов
-Ниже перечислены только названия сюжетов и их уроки. Материал, формы и способы
-проверки остаются в [инвентаризации](../research/2026-08-31-labs-inventory.md).
+Ниже перечислены сюжеты, их уроки и принятые решения по форме заданий. Исходный
+материал и способы проверки — в
+[инвентаризации](../research/2026-08-31-labs-inventory.md).
### Почему выручка не сходится
-Этот раздел рекомендован первым из факультативов. Он открывается после ступени
-1 основной программы: материал живет на стенде с этапа 6.
+Этот раздел рекомендован первым из факультативов и открывается после ступени
+1 основной программы. Шесть сюжетов собраны в четыре лабораторные: три
+расследования готовых данных и одно наблюдение во времени. Опоздания,
+изменение выручки и история отмен используют одну историю заказов, поэтому
+для них выбрана общая работа вместо трех отдельных.
-- **Почему трекер недосчитывает покупки (#186).** Разрыв воронки подтверждений
- показывает потерянные события и границу доверия к двум источникам.
-- **Почему повтор покупки завышает выручку (#187).** Движок убирает технический
- дубль строки, а повтор одной покупки приходится находить по деловому ключу.
-- **Какая позиция объясняет разницу в сумме.** Расхождение суммы нужно
- разложить до конкретной вычеркнутой позиции и объяснить до копейки.
-- **Был ли отмененный заказ оплачен.** Текущее состояние не заменяет историю, а
- некоторые различия после истечения срока хранения сырых данных уже не
- восстановить.
-- **Когда отчет за день можно считать окончательным (#188).** Отчет за день
- становится окончательным лишь после того, как источник больше не может
- изменить этот день.
-- **Почему выручка за день еще меняется.** Внутри окна изменяемости выручка
- движется, а после закрытия дня фиксируется.
+Результат расследования — запрос сверки и инженерное решение, подтвержденное
+данными: выбор источника, ключа, полей для точного расчета или правила
+пересчета и хранения истории. Объяснение суммы служит проверкой решения.
+Вариант закончить работы только объяснением показателей отклонен: он
+смещал акцент в анализ отчетов. Постройка новых DAG и подключение проверок
+к конвейеру оставлены основной программе, чтобы факультативы не повторяли
+ее задания.
+
+- **[Куда пропали покупки](../labs/optional/missing-purchases.md) (#186).**
+ Сверка по номерам заказов обосновывает источник витрины и условие
+ окончательной проверки полноты. Счетчики событий помогают обнаружить
+ расхождение, но не устанавливают место потери.
+- **[Почему повтор покупки завышает выручку](../labs/optional/duplicate-purchases.md) (#187).**
+ Учащийся собирает расчет с одной строкой на заказ, проверяет ключ и полноту
+ набора номеров, отличает его зерно от зерна исходных событий.
+- **[Какая позиция объясняет разницу в сумме](../labs/optional/order-amount-difference.md).**
+ Сверка позиций обосновывает выбор исходных полей и типа денег, отделяет
+ ошибку собственного разбора от различия данных события и заказа.
+- **[Почему вчерашняя выручка меняется сегодня](../labs/optional/revenue-over-time.md) (#188).**
+ Наблюдение опозданий и смены статусов обосновывает правило пересчета при
+ ежедневной загрузке без пропусков. Учащийся определяет момент закрытия дня
+ и требования к истории для воспроизведения расчета и проверки факта оплаты.
+
+В четвертой лабораторной выбрано собственное наблюдение: учащийся продвигает
+мир по одному дню через Airflow и сохраняет результаты после обновления витрин
+в две локальные таблицы ClickHouse под `student`: состояния заказов и итоги
+наблюдений. Сравнение выполняется SQL-запросами в том же подключении DBeaver.
+Учащийся может писать только в `default`; источники доступны ему на чтение.
+Отдельная строка итогов фиксирует и наблюдение без заказов. Экспорт в CSV
+отклонен как основной способ: он требовал неописанного перехода к другому
+инструменту или обратному импорту файлов.
+Готовые слепки и сочетание готового примера с собственным наблюдением
+отклонены: нужную историю можно получить на стенде и самому увидеть,
+как новые данные меняют уже рассчитанный отчет.
+
+День считается закрытым после получения и обработки последнего слепка
+семидневного окна изменяемости; этот слепок доставляется следующим модельным
+днем. Одного совпадения сумм в соседних наблюдениях для такого вывода
+недостаточно. История отмен тоже имеет границу: оплата и отмена между двумя
+дневными слепками могут остаться невидимыми. Отсутствие наблюдаемой оплаты
+не доказывает, что оплаты не было.
### Тонкий анализ данных
diff --git a/generator/src/clickstream_generator/schema.py b/generator/src/clickstream_generator/schema.py
index d31d3f4..0082f80 100644
--- a/generator/src/clickstream_generator/schema.py
+++ b/generator/src/clickstream_generator/schema.py
@@ -420,7 +420,7 @@ COLUMNS: tuple[Column, ...] = (
numpy_dtype="int64",
normalized_name="product_price",
group=ColumnGroup.ECOMMERCE,
- comment="цена за штуку целым числом: деньги генератор считает целыми",
+ comment="цена за штуку, округленная до целых рублей",
),
Column(
name="productQuantity",
diff --git a/infra/clickhouse/users.d/access.xml b/infra/clickhouse/users.d/access.xml
index 04cc4a0..5cf2bd5 100644
--- a/infra/clickhouse/users.d/access.xml
+++ b/infra/clickhouse/users.d/access.xml
@@ -61,6 +61,11 @@
GRANT SELECT ON system.settings
+
+
+ GRANT SELECT, INSERT, CREATE TABLE, DROP TABLE ON default.*
+
+
GRANT SELECT ON stg.*
@@ -113,6 +118,14 @@
GRANT analyst_reader
+
+
+ default
+
+ GRANT analyst_reader
+ GRANT student_writer
+
+
default