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>
This commit is contained in:
2026-08-18 12:30:13 +03:00
co-authored by Claude Opus 5
parent 716eafe865
commit b2fcc7e593
14 changed files with 650 additions and 52 deletions
@@ -0,0 +1,109 @@
"""Заказы бэкенда: вторая проекция покупки и деньги магазина.
Заказ — не второе порождение, а вторая проекция того же факта мира. Корзину,
цены, промокод и номер посчитала торговая половина дня-функции; заказная
половина берёт её покупки готовой структурой и добавляет то, чего у клиента
нет: личность покупателя под родным именем `user_id` и деньги магазина —
скидку по коду, доставку и итог. Новых бросков в торговый подпоток заказная
половина не делает, поэтому два источника согласованы по построению, а не
сверкой (docs/architecture/orders/snapshot.md).
**Деньги — целыми копейками**, как и везде в генераторе: `items_total` —
сумма позиций, посчитанная торговой половиной (у клиента то же число зовётся
выручкой); `discount` — скидка по промокоду события, по таблице «код →
скидка» из чисел мира; `delivery` — единственные деньги заказа, которых нет
ни в одном событии; `total` — `items_total` `discount` + `delivery`.
Отсюда правило витрин «деньги считаем по бэкенду»: про скидку и доставку
клиент не знает вовсе.
**Случайность — подпоток заказной стороны**, ветвящийся по дню рождения
заказа: слепок несёт семь дней рождения сразу и судьбу каждого заказа обязан
читать из его собственного дня. Броски делаются на полную длину дня, а не на
отобранных заказах, — иначе длина броска стала бы функцией доли, и правка
одной доли перебрасывала бы весь подпоток после себя
(docs/architecture/orders/fate.md).
Судьбы у заказа здесь ещё нет: статус, моменты оплаты и отмены и дельта
суммы — следующий тикет.
"""
from dataclasses import dataclass
import numpy as np
from numpy.typing import NDArray
from clickstream_generator import world
from clickstream_generator.commerce import Purchases
from clickstream_generator.seeds import Component, day_stream
from clickstream_generator.weights import pick
_DISCOUNT_PERCENT = dict(world.COUPONS)
_DELIVERY_PRICE = np.array(
[price for price, _ in world.DELIVERY_KOPECKS_WEIGHTS], dtype=np.int64
)
_DELIVERY_CUMULATIVE = np.cumsum(
[weight for _, weight in world.DELIVERY_KOPECKS_WEIGHTS]
)
@dataclass(frozen=True, slots=True)
class Orders:
"""Заказы одного модельного дня: ряды одной длины, по элементу на заказ.
Порядок — порядок рождения заказов, он же возрастание `order_id`. День
рождения заказа — `day`: строка в базе источника создаётся синхронно с
покупкой, поэтому день создания строки и день покупки совпадают.
"""
day: int
order_id: tuple[str, ...]
# Пользователь магазина: тот же человек, что стоит за купившей кукой.
user_id: NDArray[np.uint64]
# Позиции заказа: номера товаров каталога и штуки, ячейка на заказ.
product: tuple[NDArray[np.int64], ...]
quantity: tuple[NDArray[np.int64], ...]
# Деньги заказа, целые копейки.
items_total: NDArray[np.int64]
discount: NDArray[np.int64]
delivery: NDArray[np.int64]
total: NDArray[np.int64]
def __len__(self) -> int:
return self.user_id.size
def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
"""Заказы дня `day`: его покупки, к которым бэкенд добавил свои деньги."""
rng = day_stream(seed, day, Component.ORDERS)
delivery = _delivery(rng, len(purchases))
discount = _discount(purchases)
return Orders(
day=day,
order_id=purchases.order_id,
user_id=purchases.person_id,
product=purchases.product,
quantity=purchases.quantity,
items_total=purchases.revenue,
discount=discount,
delivery=delivery,
total=purchases.revenue - discount + delivery,
)
def _delivery(rng: np.random.Generator, orders: int) -> NDArray[np.int64]:
"""Стоимость доставки каждого заказа дня — броском по таблице весов."""
return _DELIVERY_PRICE[pick(rng, _DELIVERY_CUMULATIVE, orders)]
def _discount(purchases: Purchases) -> NDArray[np.int64]:
"""Скидка каждого заказа: процент промокода от суммы позиций, вниз.
Броска здесь нет: код выбрал посетитель, и он уже уехал в событие —
бэкенду остаётся прочитать таблицу. Заказ без кода скидки не получает,
а спорную копейку округление оставляет магазину.
"""
percent = np.array(
[_DISCOUNT_PERCENT[code] if code else 0 for code in purchases.coupon],
dtype=np.int64,
)
return purchases.revenue * percent // 100