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

Merged
ddmitry merged 1 commits from feat/91-order-fate into main 2026-08-18 16:06:34 +03:00
6 changed files with 264 additions and 36 deletions
+4 -1
View File
@@ -78,7 +78,10 @@
**Дельта суммы (C) — вычеркнутая позиция.** Товара не оказалось в наличии,
позицию сняли: у заказа на одну позицию меньше, чем в клиентских массивах, а
`items_total` меньше на её стоимость. Момента у неё нет — заказ приезжает
`items_total` меньше на её стоимость. Уменьшаются ровно на полную стоимость
вычеркнутой строки и `items_total`, и `total`; скидка остаётся рассчитанной от
исходной клиентской покупки и после складского вычёркивания не пересчитывается.
Момента у неё нет — заказ приезжает
урезанным во всех своих слепках: первый слепок снимается на границе суток,
когда склад заказ уже собрал. Заказ из одной позиции дельты не получает —
пустых заказов не бывает. Позиция выбирается равновероятно: корреляция со
+4 -1
View File
@@ -30,7 +30,10 @@
**Деньги заказа** складываются целыми копейками: `items_total` — сумма
позиций, посчитанная торговой половиной, то же число, что уехало клиентским
`purchaseRevenue`; `discount` — процент промокода от неё, округлённый вниз;
`purchaseRevenue`, но у заказа с вычеркнутой позицией он меньше на её полную
стоимость ([судьба заказа](fate.md)); `discount` — процент промокода от
исходной суммы клиентской покупки, округлённый вниз, и дельта его не
пересчитывает;
`delivery` — бросок заказной стороны по таблице целых весов, единственные
деньги заказа, которых нет ни в одном событии; `total` = `items_total`
`discount` + `delivery`. Отсюда и правило витрин «деньги считаем по
+114 -15
View File
@@ -10,11 +10,11 @@
**Деньги — целыми копейками**, как и везде в генераторе: `items_total` —
сумма позиций, посчитанная торговой половиной (у клиента то же число зовётся
выручкой); `discount` — скидка по промокоду события, по таблице «код →
скидка» из чисел мира; `delivery` — единственные деньги заказа, которых нет
ни в одном событии; `total` — `items_total` `discount` + `delivery`.
Отсюда правило витрин «деньги считаем по бэкенду»: про скидку и доставку
клиент не знает вовсе.
выручкой), за вычетом строки, которую унесла дельта; `discount` — скидка по
промокоду события, по таблице «код → скидка» из чисел мира; `delivery` —
единственные деньги заказа, которых нет ни в одном событии; `total` —
`items_total` `discount` + `delivery`. Отсюда правило витрин «деньги
считаем по бэкенду»: про скидку и доставку клиент не знает вовсе.
**Случайность — подпоток заказной стороны**, ветвящийся по дню рождения
заказа: слепок несёт семь дней рождения сразу и судьбу каждого заказа обязан
@@ -23,16 +23,26 @@
одной доли перебрасывала бы весь подпоток после себя
(docs/architecture/orders/fate.md).
Судьбы у заказа здесь ещё нет: статус, моменты оплаты и отмены и дельта
суммы — следующий тикет.
**Судьба заказа — исход и его моменты**: из окна изменяемости заказ выходит
оплаченным или отменённым, а когда именно это случилось, сказано смещением в
секундах от рождения заказа. Момента, которого у исхода нет, нет и в данных:
его место занимает −1, а не ноль, — иначе «оплатили в секунду рождения» было
бы не отличить от «не оплатили вовсе».
**Дельта суммы — вычеркнутая позиция**: товара не оказалось в наличии, и
заказ приезжает на строку короче клиентской корзины, а `items_total` меньше
ровно на её полную стоимость. Момента у дельты нет — склад собрал заказ до
первого слепка, поэтому урезан он во всех своих слепках. Заказ из одной
позиции дельты не получает: пустых заказов не бывает.
"""
from dataclasses import dataclass
from enum import IntEnum
import numpy as np
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.seeds import Component, day_stream
from clickstream_generator.weights import pick
@@ -44,6 +54,16 @@ _DELIVERY_PRICE = np.array(
_DELIVERY_CUMULATIVE = np.cumsum(
[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)
@@ -59,7 +79,8 @@ class Orders:
order_id: tuple[str, ...]
# Пользователь магазина: тот же человек, что стоит за купившей кукой.
user_id: NDArray[np.uint64]
# Позиции заказа: номера товаров каталога и штуки, ячейка на заказ.
# Позиции заказа: номера товаров каталога и штуки, ячейка на заказ. У
# заказа с дельтой позиций на одну меньше, чем в корзине клиента.
product: tuple[NDArray[np.int64], ...]
quantity: tuple[NDArray[np.int64], ...]
# Деньги заказа, целые копейки.
@@ -67,6 +88,11 @@ class Orders:
discount: NDArray[np.int64]
delivery: 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:
return self.user_id.size
@@ -76,17 +102,22 @@ def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
"""Заказы дня `day`: его покупки, к которым бэкенд добавил свои деньги."""
rng = day_stream(seed, day, Component.ORDERS)
delivery = _delivery(rng, len(purchases))
outcome, paid_after, cancelled_after = _fate(rng, len(purchases))
product, quantity, items_total = _delta(rng, 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,
product=product,
quantity=quantity,
items_total=items_total,
discount=discount,
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)]
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]:
"""Скидка каждого заказа: процент промокода от суммы позиций, вниз.
"""Скидка каждого заказа: процент промокода от клиентской выручки, вниз.
Броска здесь нет: код выбрал посетитель, и он уже уехал в событие —
бэкенду остаётся прочитать таблицу. Заказ без кода скидки не получает,
а спорную копейку округление оставляет магазину.
а спорную копейку округление оставляет магазину. Складская дельта скидку
не пересчитывает: `total` заказа с дельтой убывает ровно на стоимость
ушедшей строки.
"""
percent = np.array(
[_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_PURCHASE_ID = 42150002
# --- Числа заказов бэкенда: деньги магазина -------------------------------
# --- Числа заказов бэкенда: деньги магазина и судьба заказа ----------------
# Стоимость доставки: пары «копейки — вес». Бесплатно (самовывоз или акция),
# обычная курьерская, срочная. Это единственные деньги заказа, которых нет
@@ -245,3 +245,34 @@ DELIVERY_KOPECKS_WEIGHTS = (
(29_900, 50),
(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.delivery[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))
]
@@ -128,6 +131,11 @@ def test_the_snapshot_notices_everything_the_day_hands_out(weekday: day.Day):
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():
first = snapshot(day.stream(CANONICAL_SEED, 3))
+102 -18
View File
@@ -1,9 +1,9 @@
"""Заказы бэкенда: проекция покупки, деньги магазина и мост к склейке.
Заказ здесь ещё без судьбы — статуса, моментов и дельты (тикет #91): всё,
что проверяется, это согласие двух источников по построению. Поэтому и
сверяется заказ не с внутренней структурой торговой половины (это было бы
сверкой кода с самим собой), а с событием, которое уехало в трекер.
Здесь сверяются две проекции одной покупки — клиентское событие и заказ
бэкенда — и судьба заказа. Сверяется заказ не с внутренней структурой
торговой половины (это было бы сверкой кода с самим собой), а с событием,
которое уехало в трекер.
Чистота заказов от зерна и дня сторожится там же, где чистота событий, —
слепком дня в `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
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()
price = dict(zip(goods.sku.tolist(), goods.price.tolist(), strict=True))
events = purchases_of(weekday)
money = weekday.orders
deltas = 0
for number, order in enumerate(weekday.orders.order_id):
assert order == events["purchaseID"][number][0]
items = weekday.orders.product[number]
assert [goods.sku[item] for item in items.tolist()] == (
events["productID"][number].tolist()
client = list(
zip(
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` бэкенда — одно число: в событии
# оно дробное, у заказа целое в копейках.
assert weekday.orders.items_total[number] == round(
events["purchaseRevenue"][number][0] * commerce.KOPECKS
)
revenue = round(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):
@@ -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
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 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):
@@ -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)
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(
monkeypatch: pytest.MonkeyPatch,
):