diff --git a/docs/architecture/orders/fate.md b/docs/architecture/orders/fate.md index 2f27ad8..553ea79 100644 --- a/docs/architecture/orders/fate.md +++ b/docs/architecture/orders/fate.md @@ -78,7 +78,10 @@ **Дельта суммы (C) — вычеркнутая позиция.** Товара не оказалось в наличии, позицию сняли: у заказа на одну позицию меньше, чем в клиентских массивах, а -`items_total` меньше на её стоимость. Момента у неё нет — заказ приезжает +`items_total` меньше на её стоимость. Уменьшаются ровно на полную стоимость +вычеркнутой строки и `items_total`, и `total`; скидка остаётся рассчитанной от +исходной клиентской покупки и после складского вычёркивания не пересчитывается. +Момента у неё нет — заказ приезжает урезанным во всех своих слепках: первый слепок снимается на границе суток, когда склад заказ уже собрал. Заказ из одной позиции дельты не получает — пустых заказов не бывает. Позиция выбирается равновероятно: корреляция со diff --git a/docs/architecture/orders/snapshot.md b/docs/architecture/orders/snapshot.md index 9d27a53..0a74b0d 100644 --- a/docs/architecture/orders/snapshot.md +++ b/docs/architecture/orders/snapshot.md @@ -30,7 +30,10 @@ **Деньги заказа** складываются целыми копейками: `items_total` — сумма позиций, посчитанная торговой половиной, то же число, что уехало клиентским -`purchaseRevenue`; `discount` — процент промокода от неё, округлённый вниз; +`purchaseRevenue`, но у заказа с вычеркнутой позицией он меньше на её полную +стоимость ([судьба заказа](fate.md)); `discount` — процент промокода от +исходной суммы клиентской покупки, округлённый вниз, и дельта его не +пересчитывает; `delivery` — бросок заказной стороны по таблице целых весов, единственные деньги заказа, которых нет ни в одном событии; `total` = `items_total` − `discount` + `delivery`. Отсюда и правило витрин «деньги считаем по diff --git a/generator/src/clickstream_generator/orders.py b/generator/src/clickstream_generator/orders.py index 2cc6fd1..cd8855d 100644 --- a/generator/src/clickstream_generator/orders.py +++ b/generator/src/clickstream_generator/orders.py @@ -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], diff --git a/generator/src/clickstream_generator/world.py b/generator/src/clickstream_generator/world.py index 03b0579..9a0b305 100644 --- a/generator/src/clickstream_generator/world.py +++ b/generator/src/clickstream_generator/world.py @@ -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 diff --git a/generator/tests/test_day.py b/generator/tests/test_day.py index bce6ffa..3818235 100644 --- a/generator/tests/test_day.py +++ b/generator/tests/test_day.py @@ -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)) diff --git a/generator/tests/test_orders.py b/generator/tests/test_orders.py index 2dff566..91b7d1a 100644 --- a/generator/tests/test_orders.py +++ b/generator/tests/test_orders.py @@ -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, + ) ) - assert weekday.orders.quantity[number].tolist() == ( - events["productQuantity"][number].tolist() + mine = list( + zip( + [goods.sku[item] for item in money.product[number].tolist()], + money.quantity[number].tolist(), + strict=True, + ) ) # Выручка клиента и `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, ):