fix(generator): исправлен расчёт скидки после складской дельты

- Зачем:
  - слепок D7 содержал отрицательный итог заказа и попадал в брак ODS.
- Что:
  - скидка пересчитана от корзины после удаления отсутствующей позиции.
  - сериализатор отклоняет отрицательные деньги через ValueError.
  - добавлены проверки восьми стартовых дней и обновлена опись мира.
- Проверка:
  - make lint, make typecheck и make test в generator/.
  - живой world_next_day: 1694 строки приняты, таблица ошибок пуста.
This commit is contained in:
2026-08-20 13:13:14 +03:00
parent 7b89ccb529
commit 64bcd3d596
8 changed files with 123 additions and 72 deletions
+13 -11
View File
@@ -11,10 +11,11 @@
**Деньги — целыми копейками**, как и везде в генераторе: `items_total` —
сумма позиций, посчитанная торговой половиной (у клиента то же число зовётся
выручкой), за вычетом строки, которую унесла дельта; `discount` — скидка по
промокоду события, по таблице «код → скидка» из чисел мира; `delivery` —
единственные деньги заказа, которых нет ни в одном событии; `total` —
`items_total` `discount` + `delivery`. Отсюда правило витрин «деньги
считаем по бэкенду»: про скидку и доставку клиент не знает вовсе.
промокоду события от оставшегося `items_total`, по таблице «код → скидка» из
чисел мира; `delivery` — единственные деньги заказа, которых нет ни в одном
событии; `total` — `items_total` `discount` + `delivery`. Отсюда правило
витрин «деньги считаем по бэкенду»: про скидку и доставку клиент не знает
вовсе.
**Случайность — подпоток заказной стороны**, ветвящийся по дню рождения
заказа: слепок несёт семь дней рождения сразу и судьбу каждого заказа обязан
@@ -114,7 +115,7 @@ def of_day(seed: int, day: int, purchases: Purchases) -> Orders:
outcome, paid_after, cancelled_after = _fate(rng, len(purchases))
product, quantity, items_total = _delta(rng, purchases)
created_at = _created_at(rng, purchases)
discount = _discount(purchases)
discount = _discount(purchases, items_total)
return Orders(
day=day,
order_id=purchases.order_id,
@@ -262,17 +263,18 @@ def _delta(
return tuple(product), tuple(quantity), items_total
def _discount(purchases: Purchases) -> NDArray[np.int64]:
"""Скидка каждого заказа: процент промокода от клиентской выручки, вниз.
def _discount(
purchases: Purchases, items_total: NDArray[np.int64]
) -> NDArray[np.int64]:
"""Скидка каждого заказа: процент промокода от оставшейся корзины, вниз.
Броска здесь нет: код выбрал посетитель, и он уже уехал в событие —
бэкенду остаётся прочитать таблицу. Заказ без кода скидки не получает,
а спорную копейку округление оставляет магазину. Складская дельта скидку
не пересчитывает: `total` заказа с дельтой убывает ровно на стоимость
ушедшей строки.
а спорную копейку округление оставляет магазину. Склад сначала вычёркивает
отсутствующую позицию, поэтому скидка считается уже от `items_total`.
"""
percent = np.array(
[_DISCOUNT_PERCENT[code] if code else 0 for code in purchases.coupon],
dtype=np.int64,
)
return purchases.revenue * percent // 100
return items_total * percent // 100
@@ -126,7 +126,9 @@ def orders(window: Sequence[Orders], day: int) -> list[bytes]:
def _money(kopecks: int) -> str:
"""Копейки — строкой с ровно двумя знаками: `129990` → `1299.90`."""
"""Неотрицательные копейки — строкой с двумя знаками: `129990` → `1299.90`."""
if kopecks < 0:
raise ValueError(f"деньги не могут быть отрицательными: {kopecks}")
return f"{kopecks // 100}.{kopecks % 100:02d}"
+54 -27
View File
@@ -16,6 +16,7 @@ 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
@@ -34,9 +35,15 @@ def weekday() -> day.Day:
@pytest.fixture(scope="module")
def week() -> list[day.Day]:
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 [day.stream(CANONICAL_SEED, number) for number in range(WEEK)]
return start_world[:WEEK]
def purchases_of(events: day.Day) -> dict[str, NDArray[Any]]:
@@ -120,39 +127,59 @@ def test_the_order_repeats_the_purchase_up_to_one_dropped_line(weekday: day.Day)
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(
start_world: list[day.Day],
):
"""Промокод даёт скидку на корзину, которую склад оставил в заказе."""
percent = dict(world.COUPONS)
events = purchases_of(weekday)
codes = [cell[0] for cell in events["purchaseCoupon"]]
assert sum(1 for code in codes if code) > 10
discounted_delta = 0
for number, code in enumerate(codes):
# Скидка берётся от клиентской выручки, и складская дельта её не
# пересчитывает. В событии выручка дробная, у заказа — копейки.
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] < revenue
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_an_order_adds_up(weekday: day.Day):
"""`total` = `items_total` `discount` + `delivery`, целыми копейками."""
money = weekday.orders
assert np.array_equal(
money.total, money.items_total - money.discount + money.delivery
)
for column in (money.items_total, money.discount, money.delivery, money.total):
assert np.issubdtype(column.dtype, np.integer)
assert np.all(column >= 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 set(money.delivery.tolist()) == prices
# Доставка — деньги, которых нет ни в одном событии: без неё «считаем по
# бэкенду» ничего не значило бы.
assert np.any(money.total != money.items_total - money.discount)
assert deliveries == prices
def test_every_order_leaves_the_window_with_one_of_three_fates(weekday: day.Day):
+11
View File
@@ -26,6 +26,7 @@ import re
import subprocess
import sys
from contextlib import closing
from dataclasses import replace
from datetime import UTC, date, datetime, time, timedelta
from decimal import Decimal
@@ -130,6 +131,16 @@ def test_the_record_matches_the_wire_contract(days: list[day_module.Day]):
assert record["created_at"][:19] + "Z" == bought[record["order_id"]]
def test_negative_money_does_not_leave_the_source(days: list[day_module.Day]):
"""Сериализатор отвергает деньги, которых контракт провода не допускает."""
honest = days[0].orders
total = honest.total.copy()
total[0] = -1
with pytest.raises(ValueError):
serialize.orders([replace(honest, total=total)], honest.day)
def test_the_moments_carry_real_milliseconds(days: list[day_module.Day]):
"""Миллисекунды — часы базы источника, а не три дописанных нуля.