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:
@@ -39,9 +39,17 @@
|
||||
дошедшие до потока дня, в порядке событий — до всяких потерь; поэтому номер
|
||||
присваивается последним ходом, когда поток уже упорядочен.
|
||||
|
||||
**Второй выход — покупки дня.** Кроме потока событий торговая половина
|
||||
отдаёт покупки готовой структурой: номер заказа, корзина, деньги клиента,
|
||||
промокод и человек за кукой. Заказная сторона берёт их такими и ничего не
|
||||
пересчитывает: заказ — вторая проекция той же покупки, поэтому согласие двух
|
||||
источников не удерживается, а выходит по построению
|
||||
(docs/architecture/orders/snapshot.md).
|
||||
|
||||
**Случайность — подпоток `COMMERCE`** (спека генератора, раздел 2): правка
|
||||
торгового поведения не сдвигает трафиковый поток. Броски целые и векторные;
|
||||
посточно собираются только строки — их numpy не умеет.
|
||||
посточно собираются только строки — их numpy не умеет. Новых бросков заказы
|
||||
сюда не добавляют: свои решения заказная сторона тянет из своего подпотока.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -123,19 +131,61 @@ class _Draws:
|
||||
order_delay: NDArray[np.int64]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Purchases:
|
||||
"""Покупки дня готовой структурой — второй выход торговой половины.
|
||||
|
||||
Ряды одной длины, по элементу на покупку, в порядке номеров заказа.
|
||||
Корзина лежит парой рядов ячеек — номера товаров каталога и штуки; в
|
||||
каждой ячейке свой массив, длиной в число позиций покупки.
|
||||
"""
|
||||
|
||||
order_id: tuple[str, ...]
|
||||
# Человек за кукой, которая купила: у заказа он назовётся `user_id`.
|
||||
person_id: NDArray[np.uint64]
|
||||
product: tuple[NDArray[np.int64], ...]
|
||||
quantity: tuple[NDArray[np.int64], ...]
|
||||
# Промокод события строкой; у покупки без кода — пустая.
|
||||
coupon: tuple[str, ...]
|
||||
# Деньги клиента целыми копейками: сумма позиций без скидки и доставки,
|
||||
# та самая, что уехала в событие как `purchaseRevenue`. Бэкенд назовёт
|
||||
# её `items_total` — у двух источников свои имена одному числу.
|
||||
revenue: NDArray[np.int64]
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self.person_id.size
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Events:
|
||||
"""Строки одного вида торговых событий, готовые встать в поток.
|
||||
|
||||
`raw` — сырой `ecommerce` каждой строки ещё объектом: номер заказа в нём
|
||||
появится, когда поток будет упорядочен, а строка станет байтами один
|
||||
раз, каноническим сериализатором.
|
||||
раз, каноническим сериализатором. `basket` говорит, чья корзина стоит за
|
||||
строкой: по нему покупка находит свою после того, как поток упорядочен.
|
||||
"""
|
||||
|
||||
columns: dict[str, NDArray[Any]]
|
||||
page: NDArray[np.uint8]
|
||||
product: NDArray[np.int64]
|
||||
raw: list[dict[str, Any]]
|
||||
basket: NDArray[np.int64]
|
||||
|
||||
|
||||
# Что торговая половина отдаёт дню: поток целиком — колонки, страницы и
|
||||
# товары — и покупки дня вторым выходом.
|
||||
Woven = tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], Purchases]
|
||||
|
||||
# День без единой корзины: покупок в нём нет, а форма у рядов есть.
|
||||
_NO_PURCHASES = Purchases(
|
||||
order_id=(),
|
||||
person_id=np.empty(0, dtype=np.uint64),
|
||||
product=(),
|
||||
quantity=(),
|
||||
coupon=(),
|
||||
revenue=np.empty(0, dtype=np.int64),
|
||||
)
|
||||
|
||||
|
||||
def weave(
|
||||
@@ -144,24 +194,34 @@ def weave(
|
||||
columns: dict[str, NDArray[Any]],
|
||||
page: NDArray[np.uint8],
|
||||
product: NDArray[np.int64],
|
||||
) -> tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64]]:
|
||||
"""Вплетает торговые события в трафиковый поток и отдаёт поток целиком.
|
||||
person: NDArray[np.uint64],
|
||||
) -> Woven:
|
||||
"""Вплетает торговые события в поток и отдаёт поток и покупки дня.
|
||||
|
||||
Строки приходят упорядоченными по времени и такими же уходят: торговые
|
||||
события встают между ними, и поток пересобирается одним порядком.
|
||||
события встают между ними, и поток пересобирается одним порядком. Второй
|
||||
выход — покупки дня: то же самое, чем они уехали в события.
|
||||
|
||||
`person` — человек за кукой каждой строки, выровненный по входящему
|
||||
потоку: личность приходит от плана состава, а не выводится из трекера
|
||||
(docs/architecture/orders/identity.md).
|
||||
"""
|
||||
rng = day_stream(seed, day, Component.COMMERCE)
|
||||
baskets = _baskets(rng, page, product, columns["VisitID"])
|
||||
if not len(baskets):
|
||||
return columns, page, product
|
||||
return columns, page, product, _NO_PURCHASES
|
||||
|
||||
goods = catalog.catalog()
|
||||
draws = _draws(rng, baskets)
|
||||
revenue = _revenue(goods, baskets, draws)
|
||||
coupons = _coupon_codes(draws)
|
||||
events = (
|
||||
_cart_events(columns, baskets, goods, draws),
|
||||
_order_events(columns, baskets, goods, draws),
|
||||
_order_events(columns, baskets, goods, draws, revenue, coupons),
|
||||
)
|
||||
return _stream(rng, columns, page, product, events)
|
||||
columns, page, product, basket = _stream(rng, columns, page, product, events)
|
||||
purchases = _purchases(columns, basket, baskets, draws, revenue, coupons, person)
|
||||
return columns, page, product, purchases
|
||||
|
||||
|
||||
def _draws(rng: np.random.Generator, baskets: _Baskets) -> _Draws:
|
||||
@@ -307,6 +367,36 @@ def _coupons(rng: np.random.Generator, baskets: int) -> NDArray[np.int64]:
|
||||
return np.where(rng.integers(0, 100, baskets) < world.COUPON_PERCENT, code, -1)
|
||||
|
||||
|
||||
def _coupon_codes(draws: _Draws) -> NDArray[np.object_]:
|
||||
"""Промокод каждой корзины строкой; у корзины без кода — пустая.
|
||||
|
||||
Код нужен обеим сторонам: событие везёт его как есть, а бэкенд по нему
|
||||
считает скидку заказа — по той же таблице чисел мира.
|
||||
"""
|
||||
return np.array(
|
||||
[
|
||||
world.COUPONS[number][0] if number >= 0 else ""
|
||||
for number in draws.coupon.tolist()
|
||||
],
|
||||
dtype=object,
|
||||
)
|
||||
|
||||
|
||||
def _revenue(
|
||||
goods: catalog.Catalog, baskets: _Baskets, draws: _Draws
|
||||
) -> NDArray[np.int64]:
|
||||
"""Деньги клиента по корзинам, целыми копейками: сумма купленных позиций.
|
||||
|
||||
Сумма считается один раз на день и уезжает в оба источника: в событие —
|
||||
как `purchaseRevenue`, в заказ — как `items_total`. Разойтись им негде,
|
||||
и это не совпадение, а построение.
|
||||
"""
|
||||
value = goods.price[baskets.product] * draws.quantity
|
||||
kopecks = np.zeros(len(baskets), dtype=np.int64)
|
||||
np.add.at(kopecks, baskets.basket[draws.kept], value[draws.kept])
|
||||
return kopecks
|
||||
|
||||
|
||||
def _cart_events(
|
||||
columns: dict[str, NDArray[Any]],
|
||||
baskets: _Baskets,
|
||||
@@ -329,6 +419,7 @@ def _cart_events(
|
||||
page=np.full(positions, Page.PRODUCT, dtype=np.uint8),
|
||||
product=baskets.product.copy(),
|
||||
raw=[{"currencyCode": world.CURRENCY, ADD_ACTION: block} for block in blocks],
|
||||
basket=baskets.basket,
|
||||
)
|
||||
|
||||
|
||||
@@ -337,6 +428,8 @@ def _order_events(
|
||||
baskets: _Baskets,
|
||||
goods: catalog.Catalog,
|
||||
draws: _Draws,
|
||||
revenue: NDArray[np.int64],
|
||||
coupons: NDArray[np.object_],
|
||||
) -> _Events:
|
||||
"""Строки `purchase`: по одной на корзину, дошедшую до подтверждения."""
|
||||
ordered = np.flatnonzero(baskets.confirmation >= 0)
|
||||
@@ -352,14 +445,9 @@ def _order_events(
|
||||
rows.update(side)
|
||||
|
||||
# Выручка клиента — сумма позиций без скидки и доставки, целыми копейками.
|
||||
kopecks = [
|
||||
int((goods.price[baskets.product[group]] * draws.quantity[group]).sum())
|
||||
for group in bought
|
||||
]
|
||||
codes = [
|
||||
world.COUPONS[number][0] if number >= 0 else ""
|
||||
for number in draws.coupon[ordered].tolist()
|
||||
]
|
||||
# Считана она один раз на день: тем же числом её возьмёт заказ бэкенда.
|
||||
kopecks = revenue[ordered].tolist()
|
||||
codes = coupons[ordered].tolist()
|
||||
rows["purchaseRevenue"] = _cells(
|
||||
[np.array([money / KOPECKS], dtype=np.float64) for money in kopecks]
|
||||
)
|
||||
@@ -380,6 +468,7 @@ def _order_events(
|
||||
page=np.full(ordered.size, Page.CONFIRMATION, dtype=np.uint8),
|
||||
product=np.full(ordered.size, -1, dtype=np.int64),
|
||||
raw=raw,
|
||||
basket=ordered,
|
||||
)
|
||||
|
||||
|
||||
@@ -479,8 +568,15 @@ def _stream(
|
||||
page: NDArray[np.uint8],
|
||||
product: NDArray[np.int64],
|
||||
events: tuple[_Events, ...],
|
||||
) -> tuple[dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64]]:
|
||||
"""Собирает поток дня целиком: сутки режут хвост, время задаёт порядок."""
|
||||
) -> tuple[
|
||||
dict[str, NDArray[Any]], NDArray[np.uint8], NDArray[np.int64], NDArray[np.int64]
|
||||
]:
|
||||
"""Собирает поток дня целиком: сутки режут хвост, время задаёт порядок.
|
||||
|
||||
Четвёртым рядом уходит корзина каждой строки — у просмотра страницы её
|
||||
нет: по ней покупка находит свою корзину, когда поток уже упорядочен и
|
||||
полночь свой хвост отрезала.
|
||||
"""
|
||||
traffic = page.size
|
||||
trade = {
|
||||
name: np.concatenate([part.columns[name] for part in events])
|
||||
@@ -506,10 +602,17 @@ def _stream(
|
||||
place = np.full(traffic + len(raw), -1, dtype=np.int64)
|
||||
place[traffic:] = np.arange(len(raw))
|
||||
|
||||
basket = np.concatenate(
|
||||
(
|
||||
np.full(traffic, -1, dtype=np.int64),
|
||||
np.concatenate([part.basket for part in events])[alive],
|
||||
)
|
||||
)
|
||||
|
||||
order = np.lexsort((rows["WatchID"], rows["UTCEventTime"]))
|
||||
rows = {name: value[order] for name, value in rows.items()}
|
||||
_seal(rows, raw, place[order], _order_prefix(columns))
|
||||
return rows, page[order], product[order]
|
||||
return rows, page[order], product[order], basket[order]
|
||||
|
||||
|
||||
def _seal(
|
||||
@@ -537,6 +640,35 @@ def _seal(
|
||||
rows["ecommerce"][row] = orjson.dumps(block).decode()
|
||||
|
||||
|
||||
def _purchases(
|
||||
rows: dict[str, NDArray[Any]],
|
||||
basket: NDArray[np.int64],
|
||||
baskets: _Baskets,
|
||||
draws: _Draws,
|
||||
revenue: NDArray[np.int64],
|
||||
coupons: NDArray[np.object_],
|
||||
person: NDArray[np.uint64],
|
||||
) -> Purchases:
|
||||
"""Покупки дня — то же, чем они уехали в события, только структурой.
|
||||
|
||||
Порядок здесь — порядок строк потока, он же порядок номеров заказа:
|
||||
поток уже упорядочен и пронумерован, полночь свой хвост уже отрезала.
|
||||
Человек берётся у строки подтверждения — визит принадлежит одной куке,
|
||||
а кука одному человеку.
|
||||
"""
|
||||
here = np.flatnonzero(rows["EventType"] == PURCHASE)
|
||||
mine = basket[here]
|
||||
bought = [baskets.positions_of(number, draws.kept) for number in mine.tolist()]
|
||||
return Purchases(
|
||||
order_id=tuple(rows["purchaseID"][row][0] for row in here.tolist()),
|
||||
person_id=person[baskets.confirmation[mine]],
|
||||
product=tuple(baskets.product[group] for group in bought),
|
||||
quantity=tuple(draws.quantity[group] for group in bought),
|
||||
coupon=tuple(coupons[mine].tolist()),
|
||||
revenue=revenue[mine],
|
||||
)
|
||||
|
||||
|
||||
def _midnight(columns: dict[str, NDArray[Any]]) -> np.datetime64:
|
||||
"""Начало модельных суток абсолютной меткой: полночь в поясе счётчика."""
|
||||
date: np.datetime64 = columns["EventDate"][0]
|
||||
|
||||
@@ -36,15 +36,21 @@
|
||||
гарантия, ради неё мы сужаем свободу старта. Цена названа — около 24
|
||||
визитов в день из ~9,5 тыс. не начинаются в последние минуты суток.
|
||||
|
||||
**Шов с торговыми событиями.** Поток несёт, кроме колонок, два выровненных
|
||||
по строкам ряда: `page` — какая это страница магазина, и `product` — какой
|
||||
товар показывала карточка (−1 у прочих страниц). По ним `commerce` знает и
|
||||
то, куда сажать событие (карточка, подтверждение), и то, что посетитель на
|
||||
самом деле смотрел: товар в корзине, которого никто не открывал, — видимая
|
||||
глупость в воронке. Визит с назначенным заказом всегда доходит до
|
||||
`/confirmation`, а перед корзиной у него всегда есть карточка товара.
|
||||
Случайность у половин разная: трафик берёт подпоток `TRAFFIC`, торговля —
|
||||
`COMMERCE`, и правка одной не сдвигает другую.
|
||||
**Шов с торговыми событиями.** Поток несёт, кроме колонок, три выровненных
|
||||
по строкам ряда: `page` — какая это страница магазина, `product` — какой
|
||||
товар показывала карточка (−1 у прочих страниц), и `person` — человек за
|
||||
кукой этой строки. По первым двум `commerce` знает и то, куда сажать событие
|
||||
(карточка, подтверждение), и то, что посетитель на самом деле смотрел: товар
|
||||
в корзине, которого никто не открывал, — видимая глупость в воронке. Третий
|
||||
в события не попадает вовсе: личность нужна заказу, а кликстрим анонимен.
|
||||
Визит с назначенным заказом всегда доходит до `/confirmation`, а перед
|
||||
корзиной у него всегда есть карточка товара. Случайность у половин разная:
|
||||
трафик берёт подпоток `TRAFFIC`, торговля — `COMMERCE`, заказы — свой, и
|
||||
правка одной не сдвигает другие.
|
||||
|
||||
**Заказы бэкенда** день отдаёт второй половиной: покупки, которые собрала
|
||||
торговая половина, заказная превращает в заказы дня — с личностью
|
||||
покупателя и деньгами магазина (`orders`).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -53,7 +59,7 @@ from typing import Any
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from clickstream_generator import catalog, commerce, ids, plan, reference, world
|
||||
from clickstream_generator import catalog, commerce, ids, orders, plan, reference, world
|
||||
from clickstream_generator.reference import Page
|
||||
from clickstream_generator.seeds import Component, day_stream
|
||||
from clickstream_generator.weights import pick, pick_row
|
||||
@@ -78,18 +84,22 @@ _SOURCE_CUMULATIVE = np.cumsum([source.weight for source in reference.TRAFFIC_SO
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Day:
|
||||
"""Поток событий одного дня: колонки выгрузки и страницы за ними.
|
||||
"""Прожитый день: поток событий трекера и заказы бэкенда.
|
||||
|
||||
Строки упорядочены по времени — так их и проиграет проигрыватель.
|
||||
`columns` — колонки контракта схемы по его порядку, все до одной;
|
||||
`page` и `product` выровнены по тем же строкам (см. шов в докстринге
|
||||
модуля).
|
||||
модуля). `orders` — вторая половина дня: заказы, рождённые в нём.
|
||||
|
||||
Половины уезжают разными командами и в разные топики, но считаются
|
||||
вместе: заказ — проекция покупки, и порознь им разойтись негде.
|
||||
"""
|
||||
|
||||
day: int
|
||||
columns: dict[str, NDArray[Any]]
|
||||
page: NDArray[np.uint8]
|
||||
product: NDArray[np.int64]
|
||||
orders: orders.Orders
|
||||
|
||||
def __len__(self) -> int:
|
||||
return self.page.size
|
||||
@@ -149,15 +159,24 @@ def stream(seed: int, day: int) -> Day:
|
||||
|
||||
order = np.lexsort((columns["WatchID"], columns["UTCEventTime"]))
|
||||
# Торговые события садятся на готовый трафиковый поток и отдают его
|
||||
# целиком: в нём же они и упорядочиваются.
|
||||
columns, page, product = commerce.weave(
|
||||
# целиком: в нём же они и упорядочиваются. Вторым выходом приходят
|
||||
# покупки дня — из них заказная сторона собирает заказы.
|
||||
person = np.repeat(audience.person_id[visits.cookie], visits.pages)
|
||||
columns, page, product, purchases = commerce.weave(
|
||||
seed,
|
||||
day,
|
||||
{name: value[order] for name, value in columns.items()},
|
||||
page[alive][order],
|
||||
product[alive][order],
|
||||
person[alive][order],
|
||||
)
|
||||
return Day(
|
||||
day=day,
|
||||
columns=columns,
|
||||
page=page,
|
||||
product=product,
|
||||
orders=orders.of_day(seed, day, purchases),
|
||||
)
|
||||
return Day(day=day, columns=columns, page=page, product=product)
|
||||
|
||||
|
||||
def _visits(rng: np.random.Generator, audience: plan.DayAudience) -> _Visits:
|
||||
|
||||
@@ -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
|
||||
@@ -16,6 +16,9 @@
|
||||
каждая из двух кук обязана оформить заказ;
|
||||
- паспорт куки — устройство и город: они у куки одни и те же во всех её
|
||||
днях, а знает об этом только план (у пары один город на двоих);
|
||||
- личность — `person_id` человека за кукой: у двух кук пары он один, и
|
||||
заказ бэкенда показывает его как `user_id`
|
||||
(docs/architecture/orders/identity.md);
|
||||
- счётчики — приток по дням, дневная и накопленная аудитория, пары.
|
||||
|
||||
Единица дня здесь — день активности куки, а не визит: слово «визит»
|
||||
@@ -92,6 +95,8 @@ class Cohort:
|
||||
# Паспорт куки: номер профиля устройства и номер города в справочниках.
|
||||
device: NDArray[np.int64]
|
||||
city: NDArray[np.int64]
|
||||
# Человек за кукой: непрозрачный ID, одинаковый у двух кук пары.
|
||||
person_id: NDArray[np.uint64]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Когорта запоминается, поэтому массивы отдаются только на чтение.
|
||||
@@ -132,6 +137,7 @@ class Cohort:
|
||||
assigned_order=np.isin(here, ordering),
|
||||
device=self.device[here],
|
||||
city=self.city[here],
|
||||
person_id=self.person_id[here],
|
||||
)
|
||||
|
||||
|
||||
@@ -147,6 +153,8 @@ class DayAudience:
|
||||
# Паспорт куки: номера строк в справочниках устройств и городов.
|
||||
device: NDArray[np.int64]
|
||||
city: NDArray[np.int64]
|
||||
# Человек за кукой: его заказ покажет этот ID как `user_id`.
|
||||
person_id: NDArray[np.uint64]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -200,8 +208,11 @@ def cohort(seed: int, day: int) -> Cohort:
|
||||
rng, twins, active_cookie, active_day, cookies
|
||||
)
|
||||
# Паспорт бросается последним — после всего, что уже измерено: тогда
|
||||
# счётчики канонического мира от этой добавки не двигаются.
|
||||
# счётчики канонического мира от этой добавки не двигаются. По тому же
|
||||
# правилу за ним приписана личность: этап 3 дописал её в конец, и куки,
|
||||
# пары и паспорта остались теми же до байта.
|
||||
device, city = _passports(rng, twins, cookies)
|
||||
person_id = _persons(rng, twins, people, cookies)
|
||||
return Cohort(
|
||||
day=day,
|
||||
people=people,
|
||||
@@ -214,6 +225,7 @@ def cohort(seed: int, day: int) -> Cohort:
|
||||
pair_order_days=pair_order_days,
|
||||
device=device,
|
||||
city=city,
|
||||
person_id=person_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -234,6 +246,7 @@ def audience(seed: int, day: int) -> DayAudience:
|
||||
assigned_order=np.concatenate([part.assigned_order for part in parts]),
|
||||
device=np.concatenate([part.device for part in parts]),
|
||||
city=np.concatenate([part.city for part in parts]),
|
||||
person_id=np.concatenate([part.person_id for part in parts]),
|
||||
)
|
||||
|
||||
|
||||
@@ -364,3 +377,22 @@ def _passports(
|
||||
pick(rng, _DEVICE_HALF_CUMULATIVE[half], here.size)
|
||||
]
|
||||
return device, city
|
||||
|
||||
|
||||
def _persons(
|
||||
rng: np.random.Generator, twins: NDArray[np.int64], people: int, cookies: int
|
||||
) -> NDArray[np.uint64]:
|
||||
"""Человек за каждой кукой: у двух кук пары ID один и тот же.
|
||||
|
||||
Личность — минимальная форма отношения «кука принадлежит человеку», и
|
||||
знает его только план: заказ спрашивает и показывает то же число как
|
||||
`user_id`, а в кликстрим оно не попадает вовсе
|
||||
(docs/architecture/orders/identity.md). Значение непрозрачное и живёт
|
||||
ниже 2^53 — тот же потолок, что у куки: выше JSON округляет при разборе.
|
||||
"""
|
||||
person_id = np.empty(cookies, dtype=np.uint64)
|
||||
person_id[:people] = rng.integers(1, ids.LIMIT, people, dtype=np.uint64)
|
||||
# Вторая кука пары повторяет ID своего человека: заказы с двух кук —
|
||||
# это и есть склейка, ради которой пары заведены.
|
||||
person_id[twins[:, 1]] = person_id[twins[:, 0]]
|
||||
return person_id
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
│ ├── ось → номер дня: когорта этого дня
|
||||
│ └── предыстория → глубина: когорта дня до D0
|
||||
└── дни
|
||||
└── номер дня → трафик, торговля, расхождения, опоздания
|
||||
└── номер дня → трафик, торговля, заказная сторона, опоздания
|
||||
|
||||
Механизм — `numpy.random.SeedSequence`: потомок полностью определяется парой
|
||||
(зерно, позиция в дереве), а не порядком вычислений. Сверено через Context7
|
||||
@@ -35,7 +35,14 @@ class Component(IntEnum):
|
||||
|
||||
TRAFFIC = 0
|
||||
COMMERCE = 1
|
||||
DISCREPANCIES = 2
|
||||
# Заказная сторона: деньги магазина и судьба заказа. Ветвится по дню
|
||||
# рождения заказа — слепок несёт семь дней рождения сразу, и судьбу
|
||||
# каждого заказа читает из его собственного дня
|
||||
# (docs/architecture/orders/fate.md).
|
||||
ORDERS = 2
|
||||
# Событийную сторону — потерю и дубль события — заведёт этап 6; до тех
|
||||
# пор позиция занята мёртвым именем прежней нарезки, и из него никто не
|
||||
# бросает.
|
||||
LATECOMERS = 3
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,13 @@ COUNTER_TIMEZONE_MINUTES = 240 # Europe/Samara
|
||||
# зависит — иначе опись перестала бы быть воспроизводимой.
|
||||
ORIGIN = date(2026, 6, 1)
|
||||
|
||||
# Окно изменяемости заказа, модельные дни: слепок бэкенда несёт заказы,
|
||||
# рождённые в последние K дней, а за окном заказ неизменяем — выручка дня
|
||||
# «дышит» K дней и потом замерзает (мастер-спека, раздел 2). Стоит рядом с
|
||||
# D0 и поясом: это такое же свойство модельного времени, и собирает по нему
|
||||
# окно команда слепка (#92).
|
||||
ORDER_WINDOW_DAYS = 7
|
||||
|
||||
# Приток: сколько новых людей приходит в мир в средний день. Каждый приводит
|
||||
# свою куку, поэтому число это же — приток кук; вторые куки двухкуковых пар
|
||||
# добавляют к нему меньше процента.
|
||||
@@ -224,3 +231,17 @@ COUPONS = (
|
||||
# так и бывает (мастер-спека, раздел 1.2).
|
||||
GOAL_CART_ID = 42150001
|
||||
GOAL_PURCHASE_ID = 42150002
|
||||
|
||||
# --- Числа заказов бэкенда: деньги магазина -------------------------------
|
||||
|
||||
# Стоимость доставки: пары «копейки — вес». Бесплатно (самовывоз или акция),
|
||||
# обычная курьерская, срочная. Это единственные деньги заказа, которых нет
|
||||
# ни в одном событии: выручка клиента — сумма позиций, скидку по промокоду
|
||||
# считает бэкенд по таблице `COUPONS`, а доставку решает свой бросок
|
||||
# заказной стороны. Числа черновые: калибровка — пересборка эталонного
|
||||
# мира, этап 7.
|
||||
DELIVERY_KOPECKS_WEIGHTS = (
|
||||
(0, 35),
|
||||
(29_900, 50),
|
||||
(59_000, 15),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user