- Зачем: - тикет #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>
105 lines
9.6 KiB
Markdown
105 lines
9.6 KiB
Markdown
# Заказ и его слепок
|
||
|
||
Резолюция развилки [«Генератор слепков: где живёт и чем связан с
|
||
событиями»](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`**. Детерминизм даёт
|
||
его даром, а хешу слепка в описи нужен именно названный порядок.
|