docs(generator): решения #40 — торговые события

- Зачем:
  - перед реализацией торговых событий грилинг закрыл шесть развилок; без
    записи решения и отклонённые варианты потерялись бы, а часть из них
    выходит за границы тикета и меняет постановку.
- Что:
  - в раздел 9 спеки генератора добавлен блок «Решено при исполнении #40»:
    корзина шире заказа, метка покупателя становится значимой, место
    торговых событий, номер заказа, промокод, единицы денег, разная длина
    массивов purchase* и product*, orjson, хвост покупки у границы суток.
  - в раздел 8 добавлены хвосты этапам 3 и 4: скидка по промокоду и
    намеренное расхождение сумм.
  - в словарь добавлены «покупатель» и «торговое событие».
- Проверка:
  - чтением: docs/specs/2026-08-01-generator.md, разделы 8 и 9; CONTEXT.md.
This commit is contained in:
2026-08-02 17:17:31 +03:00
parent 898e00474a
commit a7442765ff
2 changed files with 86 additions and 0 deletions
+12
View File
@@ -63,11 +63,23 @@ _Избегать_: визит (визит — про сессию)
устройстве. Держит их план состава, разворачивает в поля события
день-функция; у двухкуковой пары город один на две куки, устройства разные.
**Покупатель**:
Человек, которого план состава пометил склонным покупать. Метка значима:
помеченный доходит до заказа заметно чаще прочих, но и непомеченный иногда
покупает. Из покупателей отбираются двухкуковые пары.
_Избегать_: «покупатель» про того, кто купил в конкретный день — это визит
с заказом.
**День-функция**:
Функция (зерно, номер дня), выдающая упорядоченный поток событий этих
модельных суток. Состояния между днями нет: день D не зависит от того,
прожиты ли дни до него.
**Торговое событие**:
Событие корзины или покупки — отдельная строка потока, а не просмотр
страницы. Садится на ту страницу, где случилось: корзина — на карточку
товара, покупка — на страницу подтверждения заказа.
**Каталог товаров**:
`data/catalog/products.csv` — общий справочник генератора и словаря
ClickHouse. Форма файла решена, длина — нет: строки дописываются.
+74
View File
@@ -390,6 +390,13 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
## 8. Хвосты следующим этапам
- **Этап 3 (заказы бэкенда)**: заказу, у которого в клиентском событии стоит
промокод, бэкенд обязан дать скидку — иначе данные соврут. Номер заказа обе
стороны берут один и тот же (раздел 9).
- **Этап 4 (сверка)**: расхождение сумм само не появится — цены каталога
кратны рублю, и `Float64` на таких числах не плывёт. Класс `amount_delta`
придётся создавать намеренно, вероятностью в генераторе, — мастер-спека
(раздел 4) это и допускает.
- **Этап 5 (Airflow)**: живой день со стороны хранилища — обычный ETL-даг
по расписанию (~раз в 24 минуты); генератор не дорабатывается.
- **Этап 7 (эталонный мир)**: пересборка — это манифест, не артефакт;
@@ -542,6 +549,73 @@ pytest-тест с маркером `perf` и таймаутом-обрубан
`/confirmation`, а перед корзиной у него всегда есть карточка. Своей
случайности #39 у #40 не занимает: подпоток `COMMERCE` не тронут.
Решено при исполнении #40 (2026-08-02) — решения владельца до реализации:
- **Корзина шире заказа.** Посетитель кладёт в корзину товары тех карточек,
которые открывал в этом визите, а покупает не всё: около 15% позиций
остаются брошенными. Иначе событие корзины не рассказывало бы ничего сверх
покупки — заказ был бы её точной копией, и сравнивать было бы нечего.
Замерено на каноническом мире до правки воронки (ниже): у покупающего
визита 2,1 карточки, в заказе
выходит 1,9 позиции, однопозиционных заказов около половины. Отклонено:
*заказ всегда из одного товара* — список позиций как урок умирает, а на нём
стоит единственный носитель навыка «вложенный JSON» (мастер-спека,
раздел 2); *досыпать в корзину товары, которых посетитель не открывал*
ломает шов #39 ровно там, ради чего он строился; *бросать много* (40% и
выше) — две трети заказов схлопываются в один товар.
- **Покупатель перестаёт быть словом.** Метка плана — «этот человек склонен
покупать», 5% людей когорты — до сих пор ни на что не влияла: кто купит,
решал одинаковый для всех бросок в дне. Свойство человека в данных не
читалось, постоянных покупателей в мире не было. Теперь метка правит
воронкой: помеченный доходит до заказа заметно чаще прочих, но и
непомеченный иногда покупает. Общее число покупок и общая доля визитов с
корзиной остаются прежними — меняется лишь то, кто эти визиты совершает;
точные доли — калибровка при реализации, требование сторожит тест.
Уточнение к разделу 1: вольные покупки по-прежнему решает день, но
случайность в них больше не одинакова для всех. Плата
названа: воронка живёт в коде #39, поэтому правка выходит за исходные
границы тикета и сдвигает трафиковый поток. Сейчас это ничего не стоит —
манифест ещё не собран (#42); позже обошлось бы его пересборкой.
Отклонено: *оставить как есть* — числа сходятся, но «покупатель» остаётся
именем без следа в данных; *покупают только помеченные* — 5% людей дают
около 40 заказов в день вместо 220; вернуть их можно, лишь подняв долю
покупателей до 10–30% людей, а с ней вырастет число двухкуковых пар и
измеренные числа #38 придётся пересчитывать.
- **Событие корзины садится на карточку товара**, событие покупки — на
страницу подтверждения. В жизни событие корзины шлётся нажатием кнопки на
карточке, а не открытием страницы корзины; при нескольких товарах в заказе
иначе его и не разложить — событий столько, со скольких карточек положили.
- **Номер заказа читаемый**: день модельного времени и порядковый номер
покупки в этом дне. Магазины так и нумеруют, и по номеру сразу видно день.
Он же — `order_id` бэкенда: обе стороны получают один номер, по нему
соединяется сверка (мастер-спека, раздел 4).
- **Промокод в событии есть, в сумме его нет.** Часть заказов уходит с
промокодом, но выручка, которую шлёт клиент, — сумма позиций без скидки и
доставки. Так и в жизни: код на сайте знает корзину, а не итог расчёта. Это
ровно то расхождение с бэкендом, которое менти будет раскапывать
(мастер-спека, раздел 4). Отклонено: *промокода нет никогда* — колонка
формата осталась бы мёртвой.
- **Деньги: копейки внутри, рубли в колонках, дробь только в выручке.**
Генератор считает целыми копейками; цена товара в событии — целые рубли,
как у Метрики (`productPrice` там `Int64`); дробное число одно —
`purchaseRevenue` типа `Float64`. Деление на сто верно, лишь пока цены
каталога кратны рублю: это сторожит тест, иначе копейки утекали бы молча.
- **Массивы `purchase*` и `product*` разной длины между собой.** Внутри своей
группы длина общая: `purchase*` — по элементу на заказ (у нас всегда один),
`product*` — по элементу на товар. Мастер-спека (раздел 1) говорит о
«параллельных массивах одной длины», и прочесть это как «все массивы
события одной длины» легко — поэтому в описании выгрузки сказано прямо.
- **Сырой `ecommerce` собирает канонический сериализатор.** Поле — строка
JSON внутри события, и собирать её руками значит писать второй
сериализатор со своим экранированием. Зависимость `orjson`, названная
рабочим выбором в разделе 4, появляется здесь, а не в #41.
- **Хвост покупки не выходит за полночь.** Событие покупки встаёт на
несколько секунд позже просмотра страницы подтверждения, а граница суток
режет всё, что за неё вышло. Визит с обещанным планом заказом уже подвинут
так, чтобы уместиться в сутки (#39), но подвинут по страницам — торгового
хвоста в том расчёте не было. Хвост прижимается к последней секунде суток;
иначе гарантия двухкуковых пар порвалась бы молча.
Остаётся открытым, за тикетами:
- интерфейс запуска генератора (CLI / цели make) и как он делит режимы