feat(generator): добавлена судьба заказов

- Зачем:
  - issue #91 требует решать исход и расхождение заказа в день его рождения.
- Что:
  - добавлены три исхода, моменты оплаты и отмены и дельта одной позиции.
  - обновлены проверки повторяемости и архитектурное описание заказов.
- Проверка:
  - cd generator && make lint && make typecheck && make test (418 passed).
This commit is contained in:
2026-08-18 16:02:12 +03:00
parent 62941d2ee5
commit 0d90bd566b
6 changed files with 264 additions and 36 deletions
+4 -1
View File
@@ -78,7 +78,10 @@
**Дельта суммы (C) — вычеркнутая позиция.** Товара не оказалось в наличии, **Дельта суммы (C) — вычеркнутая позиция.** Товара не оказалось в наличии,
позицию сняли: у заказа на одну позицию меньше, чем в клиентских массивах, а позицию сняли: у заказа на одну позицию меньше, чем в клиентских массивах, а
`items_total` меньше на её стоимость. Момента у неё нет — заказ приезжает `items_total` меньше на её стоимость. Уменьшаются ровно на полную стоимость
вычеркнутой строки и `items_total`, и `total`; скидка остаётся рассчитанной от
исходной клиентской покупки и после складского вычёркивания не пересчитывается.
Момента у неё нет — заказ приезжает
урезанным во всех своих слепках: первый слепок снимается на границе суток, урезанным во всех своих слепках: первый слепок снимается на границе суток,
когда склад заказ уже собрал. Заказ из одной позиции дельты не получает — когда склад заказ уже собрал. Заказ из одной позиции дельты не получает —
пустых заказов не бывает. Позиция выбирается равновероятно: корреляция со пустых заказов не бывает. Позиция выбирается равновероятно: корреляция со
+4 -1
View File
@@ -30,7 +30,10 @@
**Деньги заказа** складываются целыми копейками: `items_total` — сумма **Деньги заказа** складываются целыми копейками: `items_total` — сумма
позиций, посчитанная торговой половиной, то же число, что уехало клиентским позиций, посчитанная торговой половиной, то же число, что уехало клиентским
`purchaseRevenue`; `discount` — процент промокода от неё, округлённый вниз; `purchaseRevenue`, но у заказа с вычеркнутой позицией он меньше на её полную
стоимость ([судьба заказа](fate.md)); `discount` — процент промокода от
исходной суммы клиентской покупки, округлённый вниз, и дельта его не
пересчитывает;
`delivery` — бросок заказной стороны по таблице целых весов, единственные `delivery` — бросок заказной стороны по таблице целых весов, единственные
деньги заказа, которых нет ни в одном событии; `total` = `items_total` деньги заказа, которых нет ни в одном событии; `total` = `items_total`
`discount` + `delivery`. Отсюда и правило витрин «деньги считаем по `discount` + `delivery`. Отсюда и правило витрин «деньги считаем по
+114 -15
View File
@@ -10,11 +10,11 @@
**Деньги целыми копейками**, как и везде в генераторе: `items_total` **Деньги целыми копейками**, как и везде в генераторе: `items_total`
сумма позиций, посчитанная торговой половиной (у клиента то же число зовётся сумма позиций, посчитанная торговой половиной (у клиента то же число зовётся
выручкой); `discount` скидка по промокоду события, по таблице «код выручкой), за вычетом строки, которую унесла дельта; `discount` скидка по
скидка» из чисел мира; `delivery` единственные деньги заказа, которых нет промокоду события, по таблице «код скидка» из чисел мира; `delivery`
ни в одном событии; `total` `items_total` `discount` + `delivery`. единственные деньги заказа, которых нет ни в одном событии; `total`
Отсюда правило витрин «деньги считаем по бэкенду»: про скидку и доставку `items_total` `discount` + `delivery`. Отсюда правило витрин «деньги
клиент не знает вовсе. считаем по бэкенду»: про скидку и доставку клиент не знает вовсе.
**Случайность подпоток заказной стороны**, ветвящийся по дню рождения **Случайность подпоток заказной стороны**, ветвящийся по дню рождения
заказа: слепок несёт семь дней рождения сразу и судьбу каждого заказа обязан заказа: слепок несёт семь дней рождения сразу и судьбу каждого заказа обязан
@@ -23,16 +23,26 @@
одной доли перебрасывала бы весь подпоток после себя одной доли перебрасывала бы весь подпоток после себя
(docs/architecture/orders/fate.md). (docs/architecture/orders/fate.md).
Судьбы у заказа здесь ещё нет: статус, моменты оплаты и отмены и дельта **Судьба заказа исход и его моменты**: из окна изменяемости заказ выходит
суммы следующий тикет. оплаченным или отменённым, а когда именно это случилось, сказано смещением в
секундах от рождения заказа. Момента, которого у исхода нет, нет и в данных:
его место занимает 1, а не ноль, иначе «оплатили в секунду рождения» было
бы не отличить от «не оплатили вовсе».
**Дельта суммы вычеркнутая позиция**: товара не оказалось в наличии, и
заказ приезжает на строку короче клиентской корзины, а `items_total` меньше
ровно на её полную стоимость. Момента у дельты нет склад собрал заказ до
первого слепка, поэтому урезан он во всех своих слепках. Заказ из одной
позиции дельты не получает: пустых заказов не бывает.
""" """
from dataclasses import dataclass from dataclasses import dataclass
from enum import IntEnum
import numpy as np import numpy as np
from numpy.typing import NDArray from numpy.typing import NDArray
from clickstream_generator import world from clickstream_generator import catalog, world
from clickstream_generator.commerce import Purchases from clickstream_generator.commerce import Purchases
from clickstream_generator.seeds import Component, day_stream from clickstream_generator.seeds import Component, day_stream
from clickstream_generator.weights import pick from clickstream_generator.weights import pick
@@ -44,6 +54,16 @@ _DELIVERY_PRICE = np.array(
_DELIVERY_CUMULATIVE = np.cumsum( _DELIVERY_CUMULATIVE = np.cumsum(
[weight for _, weight in world.DELIVERY_KOPECKS_WEIGHTS] [weight for _, weight in world.DELIVERY_KOPECKS_WEIGHTS]
) )
_OUTCOME_CUMULATIVE = np.cumsum(world.ORDER_OUTCOME_WEIGHTS)
_MOMENT_HOUR_CUMULATIVE = np.cumsum(world.ORDER_MOMENT_HOUR_WEIGHTS)
class OrderOutcome(IntEnum):
"""Чем кончилось окно изменяемости заказа. Порядок — порядок весов мира."""
PAID = 0
PAID_THEN_CANCELLED = 1
UNPAID_THEN_CANCELLED = 2
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -59,7 +79,8 @@ class Orders:
order_id: tuple[str, ...] order_id: tuple[str, ...]
# Пользователь магазина: тот же человек, что стоит за купившей кукой. # Пользователь магазина: тот же человек, что стоит за купившей кукой.
user_id: NDArray[np.uint64] user_id: NDArray[np.uint64]
# Позиции заказа: номера товаров каталога и штуки, ячейка на заказ. # Позиции заказа: номера товаров каталога и штуки, ячейка на заказ. У
# заказа с дельтой позиций на одну меньше, чем в корзине клиента.
product: tuple[NDArray[np.int64], ...] product: tuple[NDArray[np.int64], ...]
quantity: tuple[NDArray[np.int64], ...] quantity: tuple[NDArray[np.int64], ...]
# Деньги заказа, целые копейки. # Деньги заказа, целые копейки.
@@ -67,6 +88,11 @@ class Orders:
discount: NDArray[np.int64] discount: NDArray[np.int64]
delivery: NDArray[np.int64] delivery: NDArray[np.int64]
total: NDArray[np.int64] total: NDArray[np.int64]
# Судьба заказа: исход (`OrderOutcome`) и его моменты — смещения в
# секундах от рождения заказа, −1 у момента, которого у исхода нет.
outcome: NDArray[np.int64]
paid_after: NDArray[np.int64]
cancelled_after: NDArray[np.int64]
def __len__(self) -> int: def __len__(self) -> int:
return self.user_id.size return self.user_id.size
@@ -76,17 +102,22 @@ def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
"""Заказы дня `day`: его покупки, к которым бэкенд добавил свои деньги.""" """Заказы дня `day`: его покупки, к которым бэкенд добавил свои деньги."""
rng = day_stream(seed, day, Component.ORDERS) rng = day_stream(seed, day, Component.ORDERS)
delivery = _delivery(rng, len(purchases)) delivery = _delivery(rng, len(purchases))
outcome, paid_after, cancelled_after = _fate(rng, len(purchases))
product, quantity, items_total = _delta(rng, purchases)
discount = _discount(purchases) discount = _discount(purchases)
return Orders( return Orders(
day=day, day=day,
order_id=purchases.order_id, order_id=purchases.order_id,
user_id=purchases.person_id, user_id=purchases.person_id,
product=purchases.product, product=product,
quantity=purchases.quantity, quantity=quantity,
items_total=purchases.revenue, items_total=items_total,
discount=discount, discount=discount,
delivery=delivery, delivery=delivery,
total=purchases.revenue - discount + delivery, total=items_total - discount + delivery,
outcome=outcome,
paid_after=paid_after,
cancelled_after=cancelled_after,
) )
@@ -95,12 +126,80 @@ def _delivery(rng: np.random.Generator, orders: int) -> NDArray[np.int64]:
return _DELIVERY_PRICE[pick(rng, _DELIVERY_CUMULATIVE, orders)] return _DELIVERY_PRICE[pick(rng, _DELIVERY_CUMULATIVE, orders)]
def _fate(
rng: np.random.Generator, orders: int
) -> tuple[NDArray[np.int64], NDArray[np.int64], NDArray[np.int64]]:
"""Исход каждого заказа и моменты, которые этот исход себе оставил.
Моментов бросается два, и всегда два обоим исходам с одним моментом
второй достаётся лишним и выбрасывается. У двух дорог заказа порядок
выходит сортировкой: ранний момент оплата, поздний отмена, а не
условной точкой отсчёта, от которой отмеряется вторая.
"""
outcome = pick(rng, _OUTCOME_CUMULATIVE, orders)
first, second = _moment(rng, orders), _moment(rng, orders)
both = outcome == OrderOutcome.PAID_THEN_CANCELLED
paid = np.where(outcome == OrderOutcome.UNPAID_THEN_CANCELLED, -1, first)
paid = np.where(both, np.minimum(first, second), paid)
cancelled = np.where(outcome == OrderOutcome.PAID, -1, first)
cancelled = np.where(both, np.maximum(first, second), cancelled)
return outcome, paid, cancelled
def _moment(rng: np.random.Generator, orders: int) -> NDArray[np.int64]:
"""Момент внутри окна: час по таблице весов и равномерная секунда в нём.
Без секунды моменты ложились бы на круглые часы, и разности времён в
аудите давали бы точные равенства там, где их в жизни не бывает.
"""
hour = pick(rng, _MOMENT_HOUR_CUMULATIVE, orders)
return hour * 3600 + rng.integers(0, 3600, orders)
def _delta(
rng: np.random.Generator, purchases: Purchases
) -> tuple[
tuple[NDArray[np.int64], ...], tuple[NDArray[np.int64], ...], NDArray[np.int64]
]:
"""Позиции заказа и сумма позиций: у выбранных заказов строкой меньше.
Бросков два, и оба на полную длину дня: признак дельты и номер позиции,
которую вычеркнул склад. Позиция выбирается равновероятно корреляцию со
спросом на такой доле не увидеть ничем, а стоила бы она таблицей чисел
мира. Верхняя граница у каждого заказа своя, число его позиций:
`Generator.integers` транслирует массивы границ (стабильная документация
NumPy, проверено 2026-08-18), поэтому хватает одного векторного броска.
Однопозиционные заказы запрещаются маской **после** обоих бросков, а не
отбором до них.
"""
goods = catalog.catalog()
count = np.array([line.size for line in purchases.product], dtype=np.int64)
marked = rng.integers(0, 100, count.size) < world.ORDER_DELTA_PERCENT
gone = rng.integers(0, count)
dropped = marked & (count > 1)
product = list(purchases.product)
quantity = list(purchases.quantity)
items_total = purchases.revenue.copy()
for order in np.flatnonzero(dropped).tolist():
line = gone[order]
# Строка уходит целиком, вместе со своей полной стоимостью.
items_total[order] -= goods.price[product[order][line]] * quantity[order][line]
product[order] = np.delete(product[order], line)
quantity[order] = np.delete(quantity[order], line)
return tuple(product), tuple(quantity), items_total
def _discount(purchases: Purchases) -> NDArray[np.int64]: def _discount(purchases: Purchases) -> NDArray[np.int64]:
"""Скидка каждого заказа: процент промокода от суммы позиций, вниз. """Скидка каждого заказа: процент промокода от клиентской выручки, вниз.
Броска здесь нет: код выбрал посетитель, и он уже уехал в событие Броска здесь нет: код выбрал посетитель, и он уже уехал в событие
бэкенду остаётся прочитать таблицу. Заказ без кода скидки не получает, бэкенду остаётся прочитать таблицу. Заказ без кода скидки не получает,
а спорную копейку округление оставляет магазину. а спорную копейку округление оставляет магазину. Складская дельта скидку
не пересчитывает: `total` заказа с дельтой убывает ровно на стоимость
ушедшей строки.
""" """
percent = np.array( percent = np.array(
[_DISCOUNT_PERCENT[code] if code else 0 for code in purchases.coupon], [_DISCOUNT_PERCENT[code] if code else 0 for code in purchases.coupon],
+32 -1
View File
@@ -232,7 +232,7 @@ COUPONS = (
GOAL_CART_ID = 42150001 GOAL_CART_ID = 42150001
GOAL_PURCHASE_ID = 42150002 GOAL_PURCHASE_ID = 42150002
# --- Числа заказов бэкенда: деньги магазина ------------------------------- # --- Числа заказов бэкенда: деньги магазина и судьба заказа ----------------
# Стоимость доставки: пары «копейки — вес». Бесплатно (самовывоз или акция), # Стоимость доставки: пары «копейки — вес». Бесплатно (самовывоз или акция),
# обычная курьерская, срочная. Это единственные деньги заказа, которых нет # обычная курьерская, срочная. Это единственные деньги заказа, которых нет
@@ -245,3 +245,34 @@ DELIVERY_KOPECKS_WEIGHTS = (
(29_900, 50), (29_900, 50),
(59_000, 15), (59_000, 15),
) )
# Чем кончается окно изменяемости заказа: веса трёх исходов, в порядке
# `orders.OrderOutcome`. «Создан» навсегда мир не допускает — из окна заказ
# выходит либо оплаченным, либо отменённым. Доли черновые: калибровка —
# пересборка эталонного мира, этап 7.
ORDER_OUTCOME_WEIGHTS = (
95, # оплачен
3, # оплачен, потом отменён
2, # отменён неоплаченным
)
# Когда внутри окна случается момент заказа — оплата или отмена: веса часов
# от 0 до 143, по часу от рождения заказа. Читается по парам «сколько часов —
# с каким весом»; часов в сумме ровно 144 — самый узкий край окна, у заказа,
# рождённого в конце суток (docs/architecture/orders/fate.md). Почти всё в
# первые сутки, дальше затухание до края:
# профиль гаснет, поэтому обрыв окна в данных не виден. Веса черновые, как и
# доли исходов.
ORDER_MOMENT_HOUR_WEIGHTS = tuple(
weight
for hours, weight in ((6, 100), (18, 60), (24, 25), (48, 8), (48, 2))
for _ in range(hours)
)
# Доля заказов, у которых со склада вычеркнули позицию: товара не оказалось в
# наличии, и заказ приезжает на строку короче клиентской корзины. Бросается
# доля на все заказы дня, а достаётся дельта не всем — однопозиционному заказу
# терять нечего, и таких больше половины, — поэтому в сверке наблюдаемая доля
# выходит вдвое-втрое ниже броска, около ориентира мастер-спеки в 1–2%. Число
# черновое: калибровка — пересборка эталонного мира, этап 7.
ORDER_DELTA_PERCENT = 4
+8
View File
@@ -65,6 +65,9 @@ def orders(events: day.Day) -> list[list[Any]]:
int(theirs.discount[number]), int(theirs.discount[number]),
int(theirs.delivery[number]), int(theirs.delivery[number]),
int(theirs.total[number]), int(theirs.total[number]),
int(theirs.outcome[number]),
int(theirs.paid_after[number]),
int(theirs.cancelled_after[number]),
] ]
for number in range(len(theirs)) for number in range(len(theirs))
] ]
@@ -128,6 +131,11 @@ def test_the_snapshot_notices_everything_the_day_hands_out(weekday: day.Day):
original original
) )
moment = weekday.orders.paid_after.copy()
moment[np.flatnonzero(moment >= 0)[0]] += 1
fated = replace(weekday.orders, paid_after=moment)
assert snapshot(replace(weekday, orders=fated)) != original
def test_a_day_is_a_pure_function_of_the_seed_and_the_day(): def test_a_day_is_a_pure_function_of_the_seed_and_the_day():
first = snapshot(day.stream(CANONICAL_SEED, 3)) first = snapshot(day.stream(CANONICAL_SEED, 3))
+102 -18
View File
@@ -1,9 +1,9 @@
"""Заказы бэкенда: проекция покупки, деньги магазина и мост к склейке. """Заказы бэкенда: проекция покупки, деньги магазина и мост к склейке.
Заказ здесь ещё без судьбы статуса, моментов и дельты (тикет #91): всё, Здесь сверяются две проекции одной покупки клиентское событие и заказ
что проверяется, это согласие двух источников по построению. Поэтому и бэкенда и судьба заказа. Сверяется заказ не с внутренней структурой
сверяется заказ не с внутренней структурой торговой половины (это было бы торговой половины (это было бы сверкой кода с самим собой), а с событием,
сверкой кода с самим собой), а с событием, которое уехало в трекер. которое уехало в трекер.
Чистота заказов от зерна и дня сторожится там же, где чистота событий, Чистота заказов от зерна и дня сторожится там же, где чистота событий,
слепком дня в `test_day.py`: заказы день отдаёт наружу наравне с потоком. слепком дня в `test_day.py`: заказы день отдаёт наружу наравне с потоком.
@@ -64,24 +64,60 @@ def test_every_order_of_the_day_is_a_purchase_of_the_day(weekday: day.Day):
assert list(weekday.orders.order_id) == numbers assert list(weekday.orders.order_id) == numbers
def test_the_order_repeats_the_basket_and_the_money_of_its_purchase(weekday: day.Day): def dropped_line(client: list[tuple[Any, int]], order: list[tuple[Any, int]]):
"""Корзина и деньги клиента у заказа те же — он их не пересчитывает.""" """Единственный индекс, удалением которого корзина клиента даёт заказ."""
for gone in range(len(client)):
if client[:gone] + client[gone + 1 :] == order:
return gone
return None
def test_the_order_repeats_the_purchase_up_to_one_dropped_line(weekday: day.Day):
"""Заказ повторяет покупку или теряет ровно одну позицию с её деньгами.
Дельта единственное, чем заказ вправе разойтись с клиентом: строка
уходит целиком, вместе со своей полной стоимостью, и терять её
однопозиционному заказу нечего.
"""
goods = catalog.catalog() goods = catalog.catalog()
price = dict(zip(goods.sku.tolist(), goods.price.tolist(), strict=True))
events = purchases_of(weekday) events = purchases_of(weekday)
money = weekday.orders
deltas = 0
for number, order in enumerate(weekday.orders.order_id): for number, order in enumerate(weekday.orders.order_id):
assert order == events["purchaseID"][number][0] assert order == events["purchaseID"][number][0]
items = weekday.orders.product[number] client = list(
assert [goods.sku[item] for item in items.tolist()] == ( zip(
events["productID"][number].tolist() events["productID"][number].tolist(),
events["productQuantity"][number].tolist(),
strict=True,
)
)
mine = list(
zip(
[goods.sku[item] for item in money.product[number].tolist()],
money.quantity[number].tolist(),
strict=True,
) )
assert weekday.orders.quantity[number].tolist() == (
events["productQuantity"][number].tolist()
) )
# Выручка клиента и `items_total` бэкенда — одно число: в событии # Выручка клиента и `items_total` бэкенда — одно число: в событии
# оно дробное, у заказа целое в копейках. # оно дробное, у заказа целое в копейках.
assert weekday.orders.items_total[number] == round( revenue = round(events["purchaseRevenue"][number][0] * commerce.KOPECKS)
events["purchaseRevenue"][number][0] * commerce.KOPECKS if mine == client:
) assert money.items_total[number] == revenue
continue
gone = dropped_line(client, mine)
assert gone is not None, order
assert len(client) > 1
sku, quantity = client[gone]
line = price[sku] * quantity
assert money.items_total[number] == revenue - line
deltas += 1
# Сколько именно дельт — калибровка, а не контракт; ноль их быть не может.
assert deltas > 0
def test_the_discount_comes_from_the_coupon_of_the_event(weekday: day.Day): def test_the_discount_comes_from_the_coupon_of_the_event(weekday: day.Day):
@@ -92,11 +128,14 @@ def test_the_discount_comes_from_the_coupon_of_the_event(weekday: day.Day):
assert sum(1 for code in codes if code) > 10 assert sum(1 for code in codes if code) > 10
for number, code in enumerate(codes): for number, code in enumerate(codes):
total = int(weekday.orders.items_total[number]) # Скидка берётся от клиентской выручки, и складская дельта её не
expected = total * percent[code] // 100 if code else 0 # пересчитывает. В событии выручка дробная, у заказа — копейки.
revenue = round(events["purchaseRevenue"][number][0] * commerce.KOPECKS)
expected = revenue * percent[code] // 100 if code else 0
assert weekday.orders.discount[number] == expected assert weekday.orders.discount[number] == expected
# Скидка без кода не берётся ниоткуда, а с кодом не съедает заказ. # Скидка без кода не берётся ниоткуда, а с кодом не съедает заказ:
assert np.all(weekday.orders.discount < weekday.orders.items_total) # мерой заказа здесь та же исходная выручка, что и у самой скидки.
assert weekday.orders.discount[number] < revenue
def test_the_money_of_an_order_adds_up(weekday: day.Day): def test_the_money_of_an_order_adds_up(weekday: day.Day):
@@ -116,6 +155,51 @@ def test_the_money_of_an_order_adds_up(weekday: day.Day):
assert np.any(money.total != money.items_total - money.discount) assert np.any(money.total != money.items_total - money.discount)
def test_every_order_leaves_the_window_with_one_of_three_fates(weekday: day.Day):
"""Судьба заказа: три исхода, и моменты рассказывают ту же историю.
Инвариант выхода из окна заказ либо оплачен, либо отменён; `created`
навсегда мир не допускает. Момента, которого у исхода нет, нет и в
данных: его место занимает 1, а не ноль, иначе «оплатили в секунду
рождения» было бы не отличить от «не оплатили вовсе». Оставшиеся
моменты лежат внутри окна 144 часов таблица весов кончается там же,
где окно у самого невезучего заказа, а секунда внутри часа
равномерная: без неё разности времён аудита давали бы точные равенства.
Доли здесь не спрашиваются: их калибруют, а не фиксируют тестом.
"""
window = 144 * 3600
fate = weekday.orders
outcomes = fate.outcome.tolist()
assert len(outcomes) == len(fate)
assert set(outcomes) == {
orders.OrderOutcome.PAID,
orders.OrderOutcome.PAID_THEN_CANCELLED,
orders.OrderOutcome.UNPAID_THEN_CANCELLED,
}
for outcome, paid, cancelled in zip(
outcomes, fate.paid_after.tolist(), fate.cancelled_after.tolist(), strict=True
):
for moment in (paid, cancelled):
assert moment == -1 or 0 <= moment < window
if outcome == orders.OrderOutcome.PAID:
assert paid >= 0 and cancelled == -1
elif outcome == orders.OrderOutcome.PAID_THEN_CANCELLED:
# Ранний из двух моментов и есть оплата: порядок дорог выходит
# сортировкой, а не условной точкой отсчёта.
assert 0 <= paid <= cancelled
else:
assert paid == -1 and cancelled >= 0
seconds = {
moment % 3600
for moment in (*fate.paid_after.tolist(), *fate.cancelled_after.tolist())
if moment >= 0
}
assert len(seconds) > 1
def test_the_order_side_draws_from_its_own_named_branch( def test_the_order_side_draws_from_its_own_named_branch(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
): ):