feat(generator): добавлена судьба заказов
- Зачем: - issue #91 требует решать исход и расхождение заказа в день его рождения. - Что: - добавлены три исхода, моменты оплаты и отмены и дельта одной позиции. - обновлены проверки повторяемости и архитектурное описание заказов. - Проверка: - cd generator && make lint && make typecheck && make test (418 passed).
This commit is contained in:
@@ -78,7 +78,10 @@
|
||||
|
||||
**Дельта суммы (C) — вычеркнутая позиция.** Товара не оказалось в наличии,
|
||||
позицию сняли: у заказа на одну позицию меньше, чем в клиентских массивах, а
|
||||
`items_total` меньше на её стоимость. Момента у неё нет — заказ приезжает
|
||||
`items_total` меньше на её стоимость. Уменьшаются ровно на полную стоимость
|
||||
вычеркнутой строки и `items_total`, и `total`; скидка остаётся рассчитанной от
|
||||
исходной клиентской покупки и после складского вычёркивания не пересчитывается.
|
||||
Момента у неё нет — заказ приезжает
|
||||
урезанным во всех своих слепках: первый слепок снимается на границе суток,
|
||||
когда склад заказ уже собрал. Заказ из одной позиции дельты не получает —
|
||||
пустых заказов не бывает. Позиция выбирается равновероятно: корреляция со
|
||||
|
||||
@@ -30,7 +30,10 @@
|
||||
|
||||
**Деньги заказа** складываются целыми копейками: `items_total` — сумма
|
||||
позиций, посчитанная торговой половиной, то же число, что уехало клиентским
|
||||
`purchaseRevenue`; `discount` — процент промокода от неё, округлённый вниз;
|
||||
`purchaseRevenue`, но у заказа с вычеркнутой позицией он меньше на её полную
|
||||
стоимость ([судьба заказа](fate.md)); `discount` — процент промокода от
|
||||
исходной суммы клиентской покупки, округлённый вниз, и дельта его не
|
||||
пересчитывает;
|
||||
`delivery` — бросок заказной стороны по таблице целых весов, единственные
|
||||
деньги заказа, которых нет ни в одном событии; `total` = `items_total` −
|
||||
`discount` + `delivery`. Отсюда и правило витрин «деньги считаем по
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user