Files
clickstream-data-platform/generator/tests/test_orders.py
T
ddadmin 64bcd3d596 fix(generator): исправлен расчёт скидки после складской дельты
- Зачем:
  - слепок D7 содержал отрицательный итог заказа и попадал в брак ODS.
- Что:
  - скидка пересчитана от корзины после удаления отсутствующей позиции.
  - сериализатор отклоняет отрицательные деньги через ValueError.
  - добавлены проверки восьми стартовых дней и обновлена опись мира.
- Проверка:
  - make lint, make typecheck и make test в generator/.
  - живой world_next_day: 1694 строки приняты, таблица ошибок пуста.
2026-08-20 13:13:14 +03:00

326 lines
15 KiB
Python

"""Заказы бэкенда: проекция покупки, деньги магазина и мост к склейке.
Здесь сверяются две проекции одной покупки — клиентское событие и заказ
бэкенда — и судьба заказа. Сверяется заказ не с внутренней структурой
торговой половины (это было бы сверкой кода с самим собой), а с событием,
которое уехало в трекер.
Чистота заказов от зерна и дня сторожится там же, где чистота событий, —
слепком дня в `test_day.py`: заказы день отдаёт наружу наравне с потоком.
"""
from typing import Any
import numpy as np
import pytest
from numpy.typing import NDArray
from clickstream_generator import catalog, commerce, day, orders, plan, schema, world
from clickstream_generator.inventory import STARTING_DAYS
from clickstream_generator.seeds import CANONICAL_SEED, Component
WEEKDAY = 2
WEEK = 7
@pytest.fixture(autouse=True)
def fresh_memo():
"""Когорты запоминаются; тесты сравнивают вычисления, а не ссылки."""
plan.cohort.cache_clear()
@pytest.fixture(scope="module")
def weekday() -> day.Day:
return day.stream(CANONICAL_SEED, WEEKDAY)
@pytest.fixture(scope="module")
def start_world() -> list[day.Day]:
"""Восемь дней, которыми `make up` наполняет пустой стенд."""
return [day.stream(CANONICAL_SEED, number) for number in range(STARTING_DAYS)]
@pytest.fixture(scope="module")
def week(start_world: list[day.Day]) -> list[day.Day]:
"""Неделя мира: назначенные пары покупают в разные дни."""
return start_world[:WEEK]
def purchases_of(events: day.Day) -> dict[str, NDArray[Any]]:
"""Строки покупок дня — колонками, в порядке потока."""
here = events.columns["EventType"] == commerce.PURCHASE
return {name: value[here] for name, value in events.columns.items()}
def user_id_by_order(events: day.Day) -> dict[str, int]:
return dict(
zip(events.orders.order_id, events.orders.user_id.tolist(), strict=True)
)
def test_every_order_of_the_day_is_a_purchase_of_the_day(weekday: day.Day):
"""Заказ — вторая проекция покупки, а не второе порождение.
У всякого заказа ровно одно событие `purchase`, и номер у них один:
сойтись двум источникам больше негде — сверка стенда соединяется
именно по нему.
"""
events = purchases_of(weekday)
numbers = [cell[0] for cell in events["purchaseID"]]
assert len(numbers) > 100
assert list(weekday.orders.order_id) == numbers
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]
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,
)
)
# Выручка клиента и `items_total` бэкенда — одно число: в событии
# оно дробное, у заказа целое в копейках.
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(
start_world: list[day.Day],
):
"""Промокод даёт скидку на корзину, которую склад оставил в заказе."""
percent = dict(world.COUPONS)
discounted_delta = 0
for today in start_world:
money = today.orders
events = purchases_of(today)
codes = [cell[0] for cell in events["purchaseCoupon"]]
for number, code in enumerate(codes):
items_total = money.items_total[number]
expected = items_total * percent[code] // 100 if code else 0
assert money.discount[number] == expected, money.order_id[number]
assert money.discount[number] < items_total
revenue = round(events["purchaseRevenue"][number][0] * commerce.KOPECKS)
discounted_delta += int(bool(code) and items_total != revenue)
# Иначе проверка не отличила бы новую базу скидки от прежней.
assert discounted_delta > 0
def test_the_money_of_start_world_orders_adds_up(start_world: list[day.Day]):
"""Деньги всех восьми стартовых дней целые, связные и неотрицательные."""
deliveries: set[int] = set()
for today in start_world:
money = today.orders
assert np.array_equal(
money.total, money.items_total - money.discount + money.delivery
)
for name, column in (
("items_total", money.items_total),
("discount", money.discount),
("delivery", money.delivery),
("total", money.total),
):
assert np.issubdtype(column.dtype, np.integer)
negative = np.flatnonzero(column < 0).tolist()
assert not negative, (
today.day,
name,
[money.order_id[number] for number in negative],
)
deliveries.update(money.delivery.tolist())
prices = {price for price, _ in world.DELIVERY_KOPECKS_WEIGHTS}
# Доставка — деньги, которых нет ни в одном событии: без неё «считаем по
# бэкенду» ничего не значило бы.
assert deliveries == prices
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,
):
"""Заказная сторона берёт свой подпоток и ветвится по дню рождения заказа.
Адрес в дереве зерна и есть контракт компонента (спека генератора,
раздел 2): правка заказной механики не двигает ни трафик, ни торговлю,
а слепок читает судьбу заказа из дня, в который заказ родился.
"""
asked: list[tuple[int, int, Component]] = []
honest = orders.day_stream
def spy(seed: int, day_number: int, component: Component):
asked.append((seed, day_number, component))
return honest(seed, day_number, component)
monkeypatch.setattr(orders, "day_stream", spy)
day.stream(CANONICAL_SEED, WEEKDAY)
assert asked == [(CANONICAL_SEED, WEEKDAY, Component.ORDERS)]
def test_the_user_id_is_the_person_behind_the_cookie(weekday: day.Day):
"""Личность заказу даёт план, а не разбор трекера."""
audience = plan.audience(CANONICAL_SEED, WEEKDAY)
person = dict(
zip(audience.client_id.tolist(), audience.person_id.tolist(), strict=True)
)
events = purchases_of(weekday)
for number, cookie in enumerate(events["ClientID"].tolist()):
assert weekday.orders.user_id[number] == person[cookie]
assert np.all(weekday.orders.user_id > 0)
assert weekday.orders.user_id.max() < 2**53
def test_both_cookies_of_a_pair_order_as_one_user(week: list[day.Day]):
"""Мост к склейке: заказы с двух кук пары несут один `user_id`.
Проверяются пары, у которых оба назначенных дня попали внутрь недели:
остальные ждут своего дня за горизонтом.
"""
users: list[dict[int, set[int]]] = []
for events in week:
by_order = user_id_by_order(events)
rows = purchases_of(events)
seen: dict[int, set[int]] = {}
for cookie, number in zip(
rows["ClientID"].tolist(),
(cell[0] for cell in rows["purchaseID"]),
strict=True,
):
seen.setdefault(cookie, set()).add(by_order[number])
users.append(seen)
pairs = 0
for born in range(-world.RETURN_TAIL_DAYS, WEEK):
cohort = plan.cohort(CANONICAL_SEED, born)
for cookies, days in zip(
cohort.pair_cookies.tolist(), cohort.pair_order_days.tolist(), strict=True
):
if max(days) >= WEEK:
continue
pairs += 1
first, second = (
users[day_number][int(cohort.client_id[cookie])]
for cookie, day_number in zip(cookies, days, strict=True)
)
assert len(first | second) == 1
assert pairs > 10
def test_the_user_id_never_reaches_the_metrica_event(weekday: day.Day):
"""Кликстрим анонимен: личность держится формой контракта, а не забывчивостью.
Спрашивается двумя способами, потому что утечь личность может двумя.
Числом — тогда её видно в числовых колонках дня, и они сверяются со
всеми личностями дневной аудитории. Текстом — тогда сравнение чисел
её прозевало бы, поэтому строки покупок читаются целиком, вместе с
сырым `ecommerce`: этот блок собирается руками, и дописать в него
лишнее поле проще всего. Имени для личности в контракте схемы нет.
"""
people = set(plan.audience(CANONICAL_SEED, WEEKDAY).person_id.tolist())
assert people
names = {column.name.lower() for column in schema.COLUMNS}
assert not (names & {"userid", "user_id", "personid", "person_id"})
for name, value in weekday.columns.items():
if np.issubdtype(value.dtype, np.integer):
assert not (set(value.tolist()) & people), name
events = purchases_of(weekday)
text = "\n".join(
" ".join(str(value[row]) for value in events.values())
for row in range(events["WatchID"].size)
)
for person in weekday.orders.user_id.tolist():
assert str(person) not in text
assert set(weekday.orders.user_id.tolist()) <= people