Files
clickstream-data-platform/docs/specs/2026-08-16-orders.md
T
ddadminandClaude Fable 5 815f500127 docs(orders): собран черновик спеки второго источника
Зачем: решения семи развилок карты #69 жили только резолюциями тикетов;
перед вычитанием и приёмкой этапу 3 нужна цельная картина одним документом.

Что: docs/specs/2026-08-16-orders.md — черновик спеки заказов бэкенда из
резолюций #70–#74, #80, #81, с отклонёнными вариантами и доводами.
Расхождения с принятым внесены тем же коммитом: мастер-спека §2 (прямой
переход created → cancelled), §4 (механика класса C, чтение долей и
amount_delta), §5 (число пар из описи снято), §8 (строка слепка с хешем,
счётчики наблюдаемых классов, числа идентичности сняты); спека генератора §2
(заказная и событийная стороны вместо «расхождений и опозданий»); ADR 0008
(уточнён сдвиг отправки слепка); CONTEXT.md (термин «судьба заказа»).

Проверка: чтение; вычитание — #84, приёмка владельцем — #88.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 22:13:51 +03:00

535 lines
50 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.
# Заказы бэкенда (этап 3): слепок окна, судьба заказа, мост к склейке
Статус: черновик — впереди проход на вычитание (#84) и приёмка владельцем (#88).
Дата: 2026-08-16. Мандат — карта #69 (этап 3, родитель #5); собрано тикетом #87.
Развилки: приём (#70), генератор слепков (#71), расхождения и опоздания (#72),
мост к склейке (#73), место в стартовом мире (#74), форма записи на проводе
(#81), брак и версии в ODS (#80).
Источники: мастер-спека [«Боевой реализм стенда
(v2)»](2026-07-30-stand-v2-realism.md) — разделы 2–5 и 7;
[спека генератора](2026-08-01-generator.md);
[ADR 0008](../adr/0008-order-ingestion.md);
[ADR 0010](../adr/0010-order-versions-in-ods.md);
[спецификация приёма заказов](2026-08-16-order-ingestion.md);
[исследование формата слепка](../research/2026-08-16-order-snapshot-wire-format.md).
## Зачем
Мастер-спека решила, что у стенда есть второй источник: раз в модельный день
бэкенд магазина выгружает полный слепок заказов окна изменяемости, и деньги в
витринах считаются по нему, а не по трекеру. Как этот источник устроен — кто
порождает заказ, откуда берётся его судьба, как слепок доезжает до ODS и что из
этого видит менти, — она отложила. Карта #69 прошла семь развилок; эта спека
собирает их решения в одну картину. По ней этап 3 режется на тикеты (#89) —
после вычитания (#84) и приёмки владельцем (#88).
Здесь не переоткрывается решённое мастер-спекой: поля слепка, окно K = 7 как
константа мира, три статуса, приоритет классов расхождений, правило «поведение
и атрибуцию считаем по трекеру, деньги — по бэкенду». Рамка, перед которой
отвечает каждое решение, — «Чем меряется генератор» в спеке генератора:
конструкция внутри оправдана только наблюдаемым эффектом на выходе.
## Целевая картина одним взглядом
- **Заказ — проекция, не порождение.** Торговая половина дня-функции уже
посчитала корзину, цены, купон и номер заказа; заказная половина навешивает
судьбу и собирает слепок. Ни одного нового броска в торговом подпотоке.
- **Слепок дня D — чистая функция (зерно, D)**: состояние заказов, рождённых в
дни D−6…D, снятое на границе суток D|D+1. Отправляет его следующий прогон —
ночная выгрузка бэкенда за вчера.
- **Судьба заказа решается при рождении** и обязана уложиться в окно K либо не
случиться вовсе. На выходе из окна заказ либо `paid`, либо `cancelled`.
- **Расхождения и опоздания — часть мира, а не грязь**: два подпотока —
заказная и событийная стороны; броски независимы, пересечения выходят
арифметикой, приоритет классов работает по-настоящему.
- **Мост к склейке**: план состава владеет человеком; его непрозрачный
`person_id` заказ показывает как `user_id`, кликстрим остаётся анонимным.
- **На проводе** — один JSON-документ на заказ: деньги строками с двумя
знаками, времена RFC 3339 в UTC с миллисекундами, `items` обычным массивом.
- **Приём — пакетный забор**: одно прямое чтение Kafka в STG, два
`INSERT SELECT` в типизированный ODS и таблицу ошибок; `ods.order_snapshot`
принимает версии заказа на `ReplacingMergeTree(updated_at)`.
- **Стартовый мир отправляет семь слепков** (дни 0…6): у последнего прожитого
дня клики есть, а заказов нет, и график выручки дозаполняется по ходу мира.
## 1. Откуда берётся заказ
Резолюция развилки [«Генератор слепков: где живёт и чем связан с
событиями»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/71).
Заказ и событие `purchase` — не два порождения, а две проекции одного факта
мира. Корзина, цены, купон и номер заказа посчитаны торговой половиной
дня-функции; заказная половина берёт заказы дня готовой структурой — вторым
выходом `commerce`, — навешивает на них жизнь заказа и собирает слепок. В
подпоток `COMMERCE` не добавляется ни одного нового броска: мир не сдвигается,
согласованность двух источников не удерживается, а получается по построению.
Следствия, которые уже решены соседями и здесь только связываются:
- у всякого заказа изначально ровно одно событие `purchase`; заказ без события
в трекере — не отдельная порода, а класс B, и делает его событийная сторона
выбрасыванием события после присвоения номера (раздел 4);
- номер заказа общий у обеих проекций: `order_id` = клиентский `purchaseID`,
читаемый номер «день и порядковый номер покупки» (спека генератора, блок
#40); нумеруются все покупки, дошедшие до потока дня, — до всяких потерь;
- скидка заказа выводится из промокода события по таблице «код → скидка» —
числу мира, которое этап 3 берёт готовым (спека генератора, раздел 8).
В модели строка заказа в базе источника создаётся синхронно с покупкой, поэтому
день рождения заказа и день создания строки совпадают. Смысл полей от этого не
меняется: `created_at` и `updated_at` — аудит строки по часам базы источника,
бизнес-время покупки живёт в `purchase.UTCEventTime` (раздел 6).
Отклонено: *выводить заказ разбором собственного вывода* (`purchaseID`, сырой
`ecommerce`) — бэкенд стал бы читателем трекера ровно там, где стенд учит, что
это разные источники; *независимая модель бэкенда* (заказ первичен, событие —
эхо) — кто купил, решает воронка, а воронка — это трафик, то есть опрокидывание
всего генератора; *слепок собирает SQL стенда из событий* — второй источник
исчезает вместе с уроком «две версии правды».
## 2. Слепок и его доставка
Та же резолюция #71.
**Запуск.** Третья команда того же пакета — `snapshot --day D [--days N]`, свой
приёмник, топик `orders`. Два источника — два запуска: трекер и бэкенд видны
глазами как два производителя, каждый со своим топиком. Один прогон с двумя
выходами отклонён: экономии он не даёт (со сдвигом отправки окно слепка и
сыгранный день не пересекаются вовсе), а правило «приёмник выбирается тем, что
для него назвали» ломает.
**Сборка окна.** Слепок дня D несёт заказы, рождённые в дни D−6…D, и собирается
переигровкой этих семи дней: заказы дня — производная всей воронки дня,
дешёвого пути к ним нет. Цена — семь проигрышей дня (~14 с) на слепок; подряд
идущие дни одного прогона считаются по разу, у начала оси окно усекается само.
Отклонено: *кэш заказов на томе* — состояние между прогонами; *окно держит
хранилище* — топик перестаёт нести слепок; *K = 1* — это страховочный срез 1
мастер-спеки, он в резерве.
**Отправка.** Слепок **снимается на границе суток, а отправляется следующим
прогоном**: даг, играющий день D, отправляет слепок дня D−1 — ночная выгрузка
бэкенда за вчера, как в бою. Содержимое слепка — чистая функция (зерно, D), от
момента отправки не зависит. Следствия:
- живой день перестаёт быть особым случаем: своего дага у него нет, слепок
живого дня отправит следующий прогон;
- пропущенный день лечится окном: слепок переснимается и даёт те же байты,
отдельного механизма самовосстановления нет;
- покупки текущего дня в сверке всегда `awaiting_order` — сюжет «вчера не
сходилось, сегодня сошлось», ради которого мастер-спека этот класс завела;
- цена — один лишний проигрыш дня на прогон (окно и сыгранный день не
пересекаются, проигрышей всегда восемь).
На старте оси дня −1 нет, поэтому прогон дня 0 не отправляет ничего; первый
слепок — дня 0 — уезжает прогоном дня 1 (исследование формата).
**Случайность.** Судьбу заказов бросает свой подпоток, ветвящийся по дню
рождения заказа: слепок несёт семь дней рождения сразу, и судьбу каждого заказа
обязан читать из его собственного дня. Вся судьба решается при рождении,
поэтому слепок любого дня — чтение готовой судьбы, а не накопление состояния.
Отклонено: *дописывать броски в конец `COMMERCE`* — правка заказа и правка
торгового поведения стали бы одним рычагом; *бросать состояние в подпотоке дня
слепка* — траектория заказа зависела бы от того, какие слепки снимали.
## 3. Судьба заказа
Резолюция развилки [«Расхождения A–D и опоздания: механика, доли и что
обещано»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/72),
ревизия после двух холодных ревью.
**Рамка.** Расхождение — не грязь и не шум, а часть мира: судьба заказа,
решённая при его рождении и уложенная в окно K целиком.
**Два подпотока дня.** Заказная сторона — судьба заказа: исход, моменты,
дельта, опоздание. Событийная сторона — порча событийного потока: потеря и
дубль. Довод за разделение — различимость по описи: правка заказной механики не
двигает хеши событий, правка событийной не двигает байты слепка, и по
покрасневшим хешам видно, какую сторону трогали. Прежние имена `DISCREPANCIES`
и `LATECOMERS` решения не переживают — они названы по классам витрины, а
компонент называет часть мира; новые стороны занимают те же позиции, имена — при
реализации. Отклонено: *один компонент на всю судьбу* — правка событийной
механики молча меняла бы байты слепка; *компонент на класс* — пять имён под
ручки калибровки, которые крутятся разом.
**Правило формы, без которого разделение не работает: броски заказной стороны
делаются на полную длину дня, а не на отобранных заказах.** Иначе длина броска
становится функцией доли, и правка одной доли перебрасывает весь подпоток после
себя (в генераторе такое место уже есть — `_at_least_one`; здесь оно не
повторяется). Изоляцию даёт форма броска, а не число подпотоков.
**Путь по статусам.** Три исхода, все внутри окна: **оплачен**; **оплачен и
отменён**; **не оплачен и отменён**. Инвариант на выходе из окна: заказ либо
`paid`, либо `cancelled`; `created` — только промежуточное состояние. За окном
будущего у заказа нет, а заказ, навсегда застрявший в `created`, — модельная
небрежность, которой в выгрузке живого магазина соответствия нет. Поэтому
нового значения `mismatch_class` не нужно: `cancelled` покрывает обе дороги
отмены (шестое значение занято `awaiting_order`).
**Форма броска.**
1. **Исход** — таблица долей из трёх строк; доля неоплаченных пишется явной
строкой, а не оставляется читателю складывать хвост в уме.
2. **Моменты** — таблица целых весов «сколько часов от рождения — с каким
весом», строки 0…143, плюс равномерная секунда внутри часа — чтобы разности
времён аудита не давали точных равенств (урок правки #50).
3. Моментов бросается **всегда два, на полную длину дня**; у одномоментных
исходов второй выбрасывается. Где их два по существу, ранний считается
оплатой — порядок выходит сортировкой, условной точки отсчёта не нужно.
143 часа — самый узкий край окна: у заказа, рождённого в конце суток, до
последнего его слепка 144 часа. Таблица кончается там, где кончается окно у
самого невезучего: вылезти нечему, сторожа не нужно. Цена — заказ, рождённый в
начале суток, не использует почти сутки своего окна; в хвосте таблицы веса
мизерные, в данных это не видно. Форма из #71 — «вес за краем окна означает
„не оплачен никогда“» — этим отменена: такой заказ теперь отменяется, а край
окна не выражается числом часов — иначе доля неоплаченных стала бы функцией
часа покупки, и менти нашёл бы этот наклон первым же разрезом.
**Доли.** Ориентир мастер-спеки ~5% читается как доля отменённых вообще; как
она делится между двумя дорогами — строки таблицы исходов. Точные числа —
калибровка этапа 7; проверок вида «отмен от 4 до 6 процентов» не заводим.
**Что из двух дорог видно.** В `dds.order` дороги неразличимы — там последняя
версия; различает их история версий: сырьё STG и физические версии
`ods.order_snapshot` до фоновых слияний, а в витринах — выручка дня, которая
сначала выросла, потом убыла. Полное различение не обещано: слепок — состояние
на границе суток, и оплата с отменой в один день в сырье неразличимы; то же у
сильно опоздавших, приехавших уже терминальными. Точную форму этого урока
решает этап 4. Отклонено: *отмена только после оплаты* — неоплаченному некуда
деться, кроме как остаться брошенным; *мгновенная отмена при рождении*
«дыхание» окна на отменах исчезает; *отмен нет вовсе* — страховочный срез 1,
он в резерве.
## 4. Классы расхождений и опоздание
Та же резолюция #72. Классы и ориентиры долей — мастер-спека, раздел 4;
здесь — механика каждого.
**Дельта суммы (C) — вычеркнутая позиция.** Товара не оказалось в наличии,
позицию сняли: у заказа на одну позицию меньше, чем в клиентских массивах, а
`items_total` меньше на её стоимость. Момента у неё нет — заказ приезжает
урезанным во всех своих слепках: первый слепок снимается на границе суток,
когда склад заказ уже собрал. Заказ из одной позиции дельты не получает —
пустых заказов не бывает. Позиция выбирается равновероятно: корреляция со
спросом на доле 1–2% статистически ненаблюдаема — менти платил бы за неё
таблицей чисел мира, а увидеть не мог бы ничем. Доводы за вычёркивание:
остаток — сотни рублей, он торчит в витрине сверки сам; расхождение
объясняется сравнением позиций — разбором вложенного JSON и `ARRAY JOIN`,
ровно тем навыком, ради которого позиции разбираются; история рассказывается
словами без легенды про генератор. Отклонено: *переоценка позиции* и *другое
количество* — дельта в десятки рублей, её надо захотеть заметить; *чистая
дельта без истории* — тупик, объяснить нечем; *врёт клиент, а не бэкенд*
заказ у нас проекция той же корзины.
**Потеря события (B) — точечная.** Уходит строка `purchase`, просмотр
`/confirmation` остаётся: события уезжают разными запросами, потерять один и
сохранить другой — обычное дело. Единственный вариант, при котором потеря
видна со стороны трекера: до подтверждения дошли сто, покупок девяносто семь.
**Дубль события (D) — сюжетный.** Обновление страницы шлёт и просмотр, и
покупку. Довод не в связности легенды: точечный дубль ломал бы урок соседнего
класса — разрыв воронки, на котором держится потеря, сжался бы втрое; при
сюжетном разрыв снова равен доле потерь. `purchaseID`, суммы и позиции у дубля
один в один — посчитал наивно, удвоил выручку. Дубль случается только там, где
до следующего визита куки есть место больше таймаута: иначе сборка сессий у
менти разошлась бы с `VisitID` — сломался бы эталон, ради которого `VisitID`
в потоке лежит. Задержка дубля — секунды-минуты, короче таймаута визита,
поэтому `VisitID` тот же. Полночь режет дубль парой — просмотр вместе с
покупкой, по тому же правилу, что у подтверждения с торговым хвостом. Обе
порчи случаются после присвоения номеров. Отклонено: *обе точечные* — дубль
затирает урок потери; *обе сюжетные* — потеря перестаёт быть видна со стороны
трекера.
**Гарантия моста сильнее порчи.** Назначенные планом покупки не теряются, и
назначенные планом заказы не опаздывают: `dds.identity_map` строится из моста
«`purchase` ↔ заказ», и выброшенное событие — как и заказ, не попавший ни в
один снятый слепок, — уносит куку из карты. Это был бы отказ лабы склейки, а
не расхождение в данных; менти различить не может. Гарантия плана старше
расхождения.
**Опоздание — заказ прячется от ранних слепков.** `created_at` честный, но в
слепках дней d…d+δ−1 строки нет, а с d+δ она появляется в том состоянии, до
которого заказ дожил: отменённый на второй день и опоздавший на третий приедет
в первом же своём слепке как `cancelled` — «выгрузка догоняет жизнь». Задержка
— таблица весов из трёх строк: 0 на подавляющем весе, 1 и 2 — это и есть
«D+1/D+2» мастер-спеки. Дальше таблица не идёт: заказ с δ = 6 приехал бы ровно
в одном слепке, и обещание «пропущенный день ничего не ломает» на нём
перестало бы быть верным; при δ ≤ 2 у всякого заказа слепков не меньше пяти.
Легенда: заказ ушёл в ручную обработку и попал в выгрузку позже. Следствие для
переливки не ново: у прошлых дней окна меняется и состав, а не только суммы —
«дыхание» статусов это уже требовало. Отклонено: *сдвиг `created_at`* — заказ
уехал бы в чужую партицию, и «выручка дня D» перестала бы отвечать покупкам
дня D — сломалась бы та самая сверка, ради которой всё строится.
**Пересечения.** Броски независимы, пересечения выходят арифметикой, приоритет
мастер-спеки работает по-настоящему. Исключений два, и оба названы выше: дубль
решается только у выживших покупок, и назначенное планом не портится.
Следствие для калибровки: брошенная доля и наблюдаемая в сверке — разные числа
(часть заказов забирают победители по приоритету, часть у класса C недоступна —
однопозиционных заказов больше половины). Отклонено: *один класс на заказ*
приоритет в SQL стал бы мёртвой веткой, которую менти читает как живую.
## 5. Что хранит опись
Резолюции #72 и [«Места заказов в стартовом
мире»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/74).
> Опись хранит только то, чего движение мира не меняет.
> Хеш кладём всегда, счётчик — только когда назван его читатель.
Хеш — растяжка: он не обещает, что числа те же вечно, он обещает, что их сдвиг
не пройдёт молча, и читает его `git diff`. Счётчик — опора: обещание
конкретного числа тому, кто его прочтёт, и платит за него каждая правка
генератора.
- **У каждого отправленного слепка — своя строка с хешем байтов.** Байты
слепка не покрыты хешами дней ни при каком раскладе подпотоков — это второй
артефакт мира. Побайтовое обещание («слепок переснимается и даёт те же
байты») опись начинает сторожить. Окно изменяемости хешу не мешает: дышат
заказы, а не байты слепка — слепок дня D по-прежнему чистая функция
(зерно, D).
- **Счётчики классов расхождений — наблюдаемых, после приоритета**, по
итоговой судьбе заказов дня; единица счёта — заказ, и опись называет её
словом. Сойтись с запросом менти они могут только на днях с закрытым окном:
при N сыгранных днях таких N − 7 (день d закрывается слепком d + 6, а
последний отправленный слепок несёт день N − 2). Читатель у них придёт
этапом 4 — проверка сверки; не окажется читателя — та же бритва режет и их.
- **Контрольные числа идентичности не заводятся вовсе**: uniq известных
пользователей и число двухкуковых пар растут, пока мир едет, — опоры из них
не выходит; генератор сторожит хеш, транспорт — счёт событий.
- **Опись описывает мир, а не доставку.** Работа генератора кончается на
Kafka: дошли ли байты до `ods.order_snapshot` — вопрос стенда и его
проверок. Поэтому счёта строк у слепка в описи нет; понадобится проверка
приёма заказов — число заведётся вместе с ней.
## 6. Мост к склейке: человек, `person_id`, `user_id`
Резолюция развилки [«Мост к склейке: user_id и двухкуковые
пары»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/73).
Причинная модель: план состава порождает **человека**; ему принадлежат одна
или две куки, каждая наблюдается в кликстриме как отдельный посетитель. Визит
может оформить заказ — тогда заказная сторона представляет того же человека
как **пользователя магазина**. Это не модель аккаунта: регистрации нет, люди
без визитов не порождаются, внутреннее знание наружу не выдаётся.
План владеет устойчивой личностью и отношением «кука принадлежит человеку».
Минимальная форма — непрозрачный внутренний `person_id`, выровненный по кукам:
у двух кук пары он одинаков. Заказ выводит то же значение под родным именем
`user_id` (`UInt64`); в кликстрим ни `person_id`, ни `user_id` не попадает —
анонимность формата держится формой, а не забывчивостью сериализатора.
`person_id` — последний обычный бросок потока случайности когорты, после уже
принятых свойств: добавление личности не сдвигает куки, пары, возвраты и
паспорта. Для второй куки повторяется ID её человека. Значения — ниже 2^53.
Раздельные прогоны ничего не хранят и не согласуют: генератор событий и
команда слепка заново спрашивают один план и получают тот же `person_id`.
Наблюдаемое обещание — четыре свойства: назначенные заказы пары с разных
`ClientID` несут один `user_id`; событие Метрики не раскрывает `user_id`;
отдельные прогоны одного мира дают то же соответствие; принятый мир
воспроизводит эффект склейки без случайных ложных объединений — конкретные
числа пар контрактом описи не являются. Межкогортные столкновения принимаются
по той же дисциплине, что у случайных `ClientID`: принимается конкретный
канонический мир по внешнему результату.
Отклонено: *заказная сторона сама назначает личность* — родство кук всё равно
пришлось бы спрашивать у плана; *материализованный реестр кука↔пользователь*
состояние между прогонами без нового внешнего эффекта; *первая кука как ID
человека* — магазин оказался бы замаскированным продолжением трекера;
*структурная координата, хеш или глобальная последовательность* — больше
механики при тех же данных; *полная модель аккаунтов* — менти её не наблюдает.
## 7. Заказы в стартовом мире
Резолюция развилки #74. Стартовый мир — не полный мир, а мир, остановленный на
границе суток 7|8. Генератор играет два источника с разными темпами — поток
Метрики и ночную выгрузку магазина; разные темпы дают всё остальное.
`world-init` играет восемь дней одним прогоном `batch --day 0 --days 8`; со
сдвигом отправки уезжают **слепки дней 0…6**. Слепок дня 7 снят на границе
суток и уедет первым же ходом мира. Особого режима у стартового мира нет:
правило отправки живёт в одном месте — в проигрывателе; генератору это решение
не стоит ничего.
Что видно снаружи: у последнего прожитого дня клики есть, а заказов нет;
глубже — день 0 виден дожившим до конца окна, день 6 — только что родившимся.
**График выручки заваливается к правому краю** и дозаполняется, пока мир едет.
Это не издержка стенда, а главный наблюдаемый эффект второго источника: ночная
выгрузка отстаёт, свежие дни предварительны. На свежем стенде лаба сверки
видит целый день `awaiting_order` — норма, а не поломка; как это назвать
менти — за витринами этапа 4.
Цена принята с открытыми глазами: у 18 из 73 пар стартового мира поздний
назначенный заказ падает на день 7 (замерено на каноническом зерне), и до
первого хода мира этих пар в `dds.identity_map` нет. Опись пар не считает
(раздел 5), поэтому красной проверки из этого не выходит. Отклонено:
*дослать восьмой слепок* — исчезает `awaiting_order` на свежем стенде, в CLI
заводится рычаг, стартовый мир становится особым случаем ровно там, где #71
его убирал; *счётчик пар учится спрашивать про слепки* — число верно ровно до
первого хода мира; *отменить сдвиг отправки*`awaiting_order` пропал бы
навсегда; *подогнать план под горизонт* — мир перестал бы быть чистой функцией
зерна.
Замеры канонического зерна (сняты при резолюции): пар в плане 73, в слепках
стартового мира доезжает 55; на эталонном мире — 192 и 172; дней с закрытым
окном — один на стартовом, семь на эталонном.
## 8. Запись на проводе
Резолюция развилки [«Форма записи слепка на
проводе»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/81);
основания и проверка разбора — в [исследовании
формата](../research/2026-08-16-order-snapshot-wire-format.md).
```json
{
"order_id": "20260603-0001",
"user_id": 42,
"status": "paid",
"created_at": "2026-06-03T14:21:07.123Z",
"updated_at": "2026-06-03T14:24:18.456Z",
"items_total": "1299.90",
"discount": "0.00",
"delivery": "199.00",
"total": "1498.90",
"items": [
{"sku": "sku-17", "qty": 1, "price": "1299.90"}
],
"snapshot_date": "2026-06-07"
}
```
- **Деньги — строки с ровно двумя знаками после точки**, включая
`items[].price`: сохраняется десятичная запись источника, сумма не проходит
через JSON-число/Float. Разница с кликстримом намеренна — там
`purchaseRevenue` приезжает JSON-числом в `Array(Float64)`, а бэкенд
передаёт деньги строками для точного `Decimal`: расхождение представлений
денег в двух источниках и есть урок.
- **`items` — обычный массив JSON**, не строка с JSON внутри. В ODS колонка
остаётся `String`: приём извлекает сырой фрагмент через `JSONExtractRaw`,
а разбирается массив один раз — на границе ODS → DDS.
- **`created_at` и `updated_at`** — аудит строки по часам базы источника, UTC
с настоящими миллисекундами, одна узкая форма `YYYY-MM-DDTHH:mm:ss.SSSZ`;
`.000` выводится честно. `updated_at` монотонен по версиям одного заказа и
служит версией `ReplacingMergeTree`. Бизнес-время покупки остаётся в
`purchase.UTCEventTime`; нового `ordered_at` не добавляется — потребителя
нет.
- **`snapshot_date`** — дата завершившегося модельного дня, снятая на
исходящей границе суток; одна на всю выгрузку, не глобальная константа и не
дата отправки. Константы мира — D0, пояс модельного календаря и окно K = 7.
- **Порядок ключей** — порядок полей контракта; у позиции — `sku`, `qty`,
`price`: воспроизводимые байты без сортировки ключей.
- **Сериализация** — явная функция рядом с `events(...)` в существующем
`serialize.py`: один словарь с вложенным списком и один `orjson.dumps` на
запись. Классы кодеков, реестр схем и отдельный `dumps` для `items` не
заводятся.
Отклонено: *JSON-числа для денег* — теряют десятичную форму и провоцируют путь
через Float; *строка с JSON в `items`* — двойная сериализация и второй разбор;
*секундная точность* — отбрасывает точность аудита источника; *глобальная
`SNAPSHOT_DATE`* — смешивает правило календаря с результатом его вычисления.
## 9. Приём: от топика до ODS
Резолюции развилок [«Приём заказов: нужен ли слепку слой
сырья»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/70)
и [«Брак в слепке: куда уходит и по каким
классам»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/80).
Источник истины здесь — [ADR 0008](../adr/0008-order-ingestion.md),
[ADR 0010](../adr/0010-order-versions-in-ods.md) и [спецификация
приёма](2026-08-16-order-ingestion.md); ниже — картина одним абзацем на слой.
Топик `orders` — одна партиция; чтец байтовый (`RawBLOB`), объявлен только на
`clickhouse-01` и без `ON CLUSTER`, матвью к нему не привязана. Сырьё забирает
пакетный шаг под управлением Airflow: одно прямое чтение Kafka вставляет
порцию в `stg.orders_raw` с `_load_id` = `run_id` запуска. Один следующий
`task_id` двумя взаимодополняющими `INSERT SELECT` читает неизменный срез STG:
годные строки — в `ods.order_snapshot`, брак — в `ods.order_snapshot_errors` с
классом `not_an_object` / `keyset_mismatch` / `field_invalid`. Строгий приём
проверяет только контракт провода — 11 верхних ключей и канонические формы;
позиции и бизнес-инварианты остаются ниже границы.
`ods.order_snapshot` принимает **версии заказа** на
`ReplacingMergeTree(updated_at)`: ключ — `order_id`, партиция — день
неизменного `created_at`, шардирование по `cityHash64(order_id)`.
`snapshot_date` — происхождение строки, а не ключ публикации: прямое чтение
Kafka не задаёт границы полного слепка, поэтому прежняя замена партиции
(ADR 0008) отменена ADR 0010. Точное текущее состояние читается через
`ods.order_v`; нужен ли при сборке DDS `FINAL`, `argMax` или инкрементальный
приём — решается вместе с DDS.
Стенд получает два режима приёма рядом — поток против слепка, push против
pull, — и это опорная точка раздела 12 мастер-спеки. Тот же даг проигрывает
модельный день и забирает слепок; со сдвигом отправки он переливает слепок
предыдущего дня — ровно тот, который сам и положил в топик.
## 10. Правила кода этапа 3
Хвосты резолюций, обязательные для реализации:
- **Броски заказной стороны — на полную длину дня**, а не на отобранных
заказах (правило формы, раздел 3); моментов всегда два.
- **Целочисленная случайность** наследуется правилом кода этапа 2: таблицы
целых весов, никаких плавающих распределений; деньги — в целых копейках.
- **Подпотоки — по позиции в дереве**: стороны судьбы ветвятся по дню рождения
заказа; в `COMMERCE` новых бросков нет; `person_id` — последний бросок
когорты.
- **Окно K = 7 — константа мира** в конфигурации мира, рядом с D0 и поясом
(исследование формата).
- **Один сериализатор**: запись слепка собирает явная функция `serialize.py`,
прямых `json.dumps` по коду нет.
- **Значения идентификаторов — ниже 2^53** (`user_id` наравне с прочими).
- Спорные API (DDL ClickHouse, операторы Airflow) перед кодом сверять через
MCP Context7 — правило AGENTS.md.
## 11. Расхождения с принятым
Внесены тем же коммитом, что и эта спека:
- **Мастер-спека, раздел 2**: цепочка статусов допускает прямой переход
`created → cancelled`; на выходе из окна заказ либо `paid`, либо
`cancelled` (#72).
- **Мастер-спека, раздел 4**: у класса C названа механика — вычеркнутая
позиция; ориентир «~5%» читается как доля отменённых вообще;
«`amount_delta` — необъяснённый остаток» читать как «не объяснённый
приведением к сравнимой базе» — сравнением позиций он объясняется, на этом
стоит урок класса C; счётчики описи — наблюдаемых классов, после приоритета,
в заказах (#72).
- **Мастер-спека, раздел 5**: «опись хранит число именно таких пар» снято —
число растёт, пока мир едет; ядро лабы — неравенство
`uniq(посетителей) > uniq(людей)` — верно в любой день (#74).
- **Мастер-спека, раздел 8**: опись получает строку на слепок с хешем байтов;
контрольные числа идентичности не заводятся; «заказы и выручка по дням»
читать как «по дням с закрытым окном» (#72, #74).
- **Спека генератора, раздел 2**: в перечислении компонентов дня расхождения
и опоздания уступают место заказной и событийной сторонам (#72).
- **ADR 0008**: фраза «переливается ровно то, что он положил в топик»
уточнена — со сдвигом отправки даг переливает слепок предыдущего дня (#71).
- **CONTEXT.md**: новый термин «судьба заказа» (#72); термины «человек» /
«посетитель» / «пользователь магазина», «дата слепка», «время аудита
источника» и поправки к «Слепку» и «Описи мира» внесены ранее, при закрытии
#72#81.
Ранее внесённые той же картой изменения (уже в main): раздел 12 мастер-спеки
переписан на «поток против слепка», сенсор дневного батча снят, первый даг
переехал на этап 3 (#70); разделы 2 и 7 мастер-спеки — формат провода и
версионный ODS (#81, #80).
## 12. Решается при нарезке и позже
- **Имена подпотоков сторон судьбы** — при реализации; из мёртвых имён никто
не бросает, переименование ничего не сдвигает (#72).
- **Конкретные веса и доли** — таблицы исходов, моментов, задержки опоздания,
доли классов, стоимость доставки — калибровка при реализации; финальная
фиксация чисел — пересборка эталонного мира, этап 7. При пересборке правки
потребуют только числа, не устройство.
- **Проверки приёма** — разовая приёмка допущения «один запуск — одно чтение»
и опыты из «Рисков и проверки» спецификации приёма — тикет реализации
приёма.
- **`_load_id` выше ODS** — вместе с устройством `dds.order` (#85).
- **Контур проверок качества для расхождений** (даг DQ, `dm.dq_summary`) —
остаётся в тумане карты #69; естественное место разговора — этап 4.
- **Каноническое чтение событий `ods.event_v`** — отдельный тикет #86, к
механике заказов не привязан.