Files
clickstream-data-platform/docs/architecture/orders/snapshot.md
T
ddadminandClaude Opus 5 b2fcc7e593 feat(generator): заказы дня — проекция покупок с деньгами магазина
- Зачем:
  - тикет #90: второй источник должен смотреть на торговую половину дня
    глазами бэкенда — заказ есть проекция покупки, а не второе порождение;
    учебный результат — два источника согласованы по построению, а не
    сверкой.
- Что:
  - `commerce` отдаёт вторым выходом покупки дня: номер заказа, корзину,
    выручку клиента, промокод и человека за кукой; новых бросков в
    подпоток `COMMERCE` не добавилось, события дня не сдвинулись.
  - заведена заказная сторона: подпоток `ORDERS` на позиции 2 дерева зерна
    (прежнее мёртвое имя `DISCREPANCIES`), ветвление — по дню рождения
    заказа; `LATECOMERS` не тронут, его наполнит этап 6.
  - новый модуль `orders.py`: деньги заказа целыми копейками —
    `items_total` из корзины, `discount` по таблице «код → скидка»
    (округление вниз), `delivery` броском по таблице весов на полную длину
    дня, `total` = `items_total` − `discount` + `delivery`.
  - план состава завёл `person_id` — последним броском когорты, после
    паспортов: вторая кука пары повторяет ID своего человека, заказ
    показывает его как `user_id`, в событие Метрики он не попадает.
  - `world`: окно изменяемости `ORDER_WINDOW_DAYS` = 7 рядом с D0 и поясом,
    черновая таблица стоимости доставки.
  - слепок дня в тестах сторожит обе половины: заказы сравниваются наравне
    с потоком событий.
  - доки: имя подпотока заказной стороны и состав денег заказа записаны в
    `docs/architecture/orders`, README генератора знает про новый модуль.
- Проверка:
  - из `generator/`: make lint, make typecheck, make test (417 тестов);
    в корне — make lint.
  - опись мира не покраснела: байты восьми дней те же, заказная сторона
    события не сдвинула.

Closes #90

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:30:13 +03:00

105 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Заказ и его слепок
Резолюция развилки [«Генератор слепков: где живёт и чем связан с
событиями»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/71).
## Откуда берётся заказ
Заказ и событие `purchase` — не два порождения, а две проекции одного факта
мира. Корзина, цены, купон и номер заказа посчитаны торговой половиной
дня-функции; заказная половина берёт заказы дня готовой структурой — вторым
выходом `commerce`, — навешивает на них жизнь заказа и собирает слепок. В
подпоток `COMMERCE` не добавляется ни одного нового броска: мир не сдвигается,
согласованность двух источников не удерживается, а получается по построению.
Следствия, которые уже решены соседями и здесь только связываются:
- у всякого заказа изначально ровно одно событие `purchase`; заказ без события
в трекере — не отдельная порода, а класс B, и делает его событийная сторона
выбрасыванием события после присвоения номера
([классы расхождений](fate.md));
- номер заказа общий у обеих проекций: `order_id` = клиентский `purchaseID`,
читаемый номер «день и порядковый номер покупки» ([спека
генератора](../../specs/2026-08-01-generator.md), раздел 9); нумеруются все
покупки, дошедшие до потока дня, — до всяких потерь;
- скидка заказа выводится из промокода события по таблице «код → скидка» —
числу мира, которое этап 3 берёт готовым (спека генератора, разделы 8 и 9).
В модели строка заказа в базе источника создаётся синхронно с покупкой,
поэтому день рождения заказа и день создания строки совпадают.
**Деньги заказа** складываются целыми копейками: `items_total` — сумма
позиций, посчитанная торговой половиной, то же число, что уехало клиентским
`purchaseRevenue`; `discount` — процент промокода от неё, округлённый вниз;
`delivery` — бросок заказной стороны по таблице целых весов, единственные
деньги заказа, которых нет ни в одном событии; `total` = `items_total`
`discount` + `delivery`. Отсюда и правило витрин «деньги считаем по
бэкенду»: про скидку и доставку клиент не знает вовсе.
Отклонено: *выводить заказ разбором собственного вывода* (`purchaseID`, сырой
`ecommerce`) — бэкенд стал бы читателем трекера ровно там, где стенд учит, что
это разные источники; *независимая модель бэкенда* (заказ первичен, событие —
эхо) — кто купил, решает воронка, а воронка — это трафик, то есть опрокидывание
всего генератора; *слепок собирает SQL стенда из событий* — второй источник
исчезает вместе с уроком «две версии правды».
## Слепок и его доставка
**Запуск.** Третья команда того же пакета — `snapshot --day D [--days N]`,
свой приёмник, топик `orders`. Два источника — два запуска: трекер и бэкенд
видны глазами как два производителя, каждый со своим топиком. Один прогон с
двумя выходами отклонён: экономии он не даёт (со сдвигом отправки окно слепка
и сыгранный день не пересекаются вовсе), а правило «приёмник выбирается тем,
что для него назвали» ломает. Отклонены также: *отдельный пакет и образ*
библиотека на двоих ради одной команды; *генератор пишет слепок файлом, в
топик льёт даг* — второй путь доставки и второй сериализатор; *слепок едет
топиком `hits`* — убивает два режима приёма.
**Сборка окна.** Слепок дня D несёт заказы, рождённые в дни D−6…D, и
собирается переигровкой этих семи дней: заказы дня — производная всей воронки
дня, дешёвого пути к ним нет. Цена — семь проигрышей дня (~14 с) на слепок; у
начала оси окно усекается само. Отклонено: *кэш заказов на томе* — состояние
между прогонами; *окно держит хранилище* — топик перестаёт нести слепок;
*K = 1* — это страховочный срез 1 мастер-спеки, он в резерве.
**Отправка.** Слепок **снимается на границе суток, а отправляется следующим
прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка
бэкенда за вчера, как в бою. Содержимое слепка — чистая функция (зерно, D), от
момента отправки не зависит. Следствия:
- живой день перестаёт быть особым случаем: своего дага у него нет, слепок
живого дня отправит следующий прогон;
- пропущенный день лечится окном: слепок переснимается и даёт те же байты,
отдельного механизма самовосстановления нет;
- покупки текущего дня в сверке всегда `awaiting_order` — сюжет «вчера не
сходилось, сегодня сошлось», ради которого мастер-спека этот класс завела;
- цена — один лишний проигрыш дня на прогон (окно и сыгранный день не
пересекаются, проигрышей всегда восемь).
На старте оси дня −1 нет, поэтому прогон дня 0 не отправляет ничего; первый
слепок — дня 0 — уезжает прогоном дня 1 ([исследование
формата](../../research/2026-08-16-order-snapshot-wire-format.md)).
**Случайность.** Судьбу заказов бросает свой подпоток, ветвящийся по дню
рождения заказа: слепок несёт семь дней рождения сразу, и судьбу каждого
заказа обязан читать из его собственного дня. Вся судьба решается при
рождении, поэтому слепок любого дня — чтение готовой судьбы, а не накопление
состояния. Отклонено: *дописывать броски в конец `COMMERCE`* — правка заказа
и правка торгового поведения стали бы одним рычагом; *бросать состояние в
подпотоке дня слепка* — траектория заказа зависела бы от того, какие слепки
снимали.
## Запись на проводе
Контракт провода — на заказ один JSON-документ: деньги строками с двумя
знаками, времена RFC 3339 в UTC с миллисекундами, `items` обычным массивом —
целиком описан мастер-спекой (раздел 2); основания, отклонённые варианты и
проверка разбора — в [исследовании
формата](../../research/2026-08-16-order-snapshot-wire-format.md) (резолюция
развилки
[«Форма записи слепка на проводе»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/81)).
Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка —
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт
его даром, а хешу слепка в описи нужен именно названный порядок.