- Зачем: - слепок D7 содержал отрицательный итог заказа и попадал в брак ODS. - Что: - скидка пересчитана от корзины после удаления отсутствующей позиции. - сериализатор отклоняет отрицательные деньги через ValueError. - добавлены проверки восьми стартовых дней и обновлена опись мира. - Проверка: - make lint, make typecheck и make test в generator/. - живой world_next_day: 1694 строки приняты, таблица ошибок пуста.
281 lines
16 KiB
Python
281 lines
16 KiB
Python
"""Заказы бэкенда: вторая проекция покупки и деньги магазина.
|
||
|
||
Заказ — не второе порождение, а вторая проекция того же факта мира. Корзину,
|
||
цены, промокод и номер посчитала торговая половина дня-функции; заказная
|
||
половина берёт её покупки готовой структурой и добавляет то, чего у клиента
|
||
нет: личность покупателя под родным именем `user_id` и деньги магазина —
|
||
скидку по коду, доставку и итог. Новых бросков в торговый подпоток заказная
|
||
половина не делает, поэтому два источника согласованы по построению, а не
|
||
сверкой (docs/architecture/orders/snapshot.md).
|
||
|
||
**Деньги — целыми копейками**, как и везде в генераторе: `items_total` —
|
||
сумма позиций, посчитанная торговой половиной (у клиента то же число зовётся
|
||
выручкой), за вычетом строки, которую унесла дельта; `discount` — скидка по
|
||
промокоду события от оставшегося `items_total`, по таблице «код → скидка» из
|
||
чисел мира; `delivery` — единственные деньги заказа, которых нет ни в одном
|
||
событии; `total` — `items_total` − `discount` + `delivery`. Отсюда правило
|
||
витрин «деньги считаем по бэкенду»: про скидку и доставку клиент не знает
|
||
вовсе.
|
||
|
||
**Случайность — подпоток заказной стороны**, ветвящийся по дню рождения
|
||
заказа: слепок несёт семь дней рождения сразу и судьбу каждого заказа обязан
|
||
читать из его собственного дня. Броски делаются на полную длину дня, а не на
|
||
отобранных заказах, — иначе длина броска стала бы функцией доли, и правка
|
||
одной доли перебрасывала бы весь подпоток после себя
|
||
(docs/architecture/orders/fate.md).
|
||
|
||
**Судьба заказа — исход и его моменты**: из окна изменяемости заказ выходит
|
||
оплаченным или отменённым, а когда именно это случилось, сказано смещением в
|
||
секундах от рождения заказа. Момента, которого у исхода нет, нет и в данных:
|
||
его место занимает −1, а не ноль, — иначе «оплатили в секунду рождения» было
|
||
бы не отличить от «не оплатили вовсе».
|
||
|
||
**Состояние на границе суток** — чтение готовой судьбы, а не накопление:
|
||
слепок дня D учитывает моменты не позже границы D|D+1 и по ним называет
|
||
статус. Отсюда «дыхание» окна — заказ, оплаченный назавтра, стоит в сегодняшнем
|
||
слепке как `created`, а завтра как `paid`.
|
||
|
||
**Дельта суммы — вычеркнутая позиция**: товара не оказалось в наличии, и
|
||
заказ приезжает на строку короче клиентской корзины, а `items_total` меньше
|
||
ровно на её полную стоимость. Момента у дельты нет — склад собрал заказ до
|
||
первого слепка, поэтому урезан он во всех своих слепках. Заказ из одной
|
||
позиции дельты не получает: пустых заказов не бывает.
|
||
"""
|
||
|
||
from dataclasses import dataclass
|
||
from enum import IntEnum
|
||
|
||
import numpy as np
|
||
from numpy.typing import NDArray
|
||
|
||
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
|
||
|
||
_DISCOUNT_PERCENT = dict(world.COUPONS)
|
||
_DELIVERY_PRICE = np.array(
|
||
[price for price, _ in world.DELIVERY_KOPECKS_WEIGHTS], dtype=np.int64
|
||
)
|
||
_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)
|
||
class Orders:
|
||
"""Заказы одного модельного дня: ряды одной длины, по элементу на заказ.
|
||
|
||
Порядок — порядок рождения заказов, он же возрастание `order_id`. День
|
||
рождения заказа — `day`: строка в базе источника создаётся синхронно с
|
||
покупкой, поэтому день создания строки и день покупки совпадают.
|
||
"""
|
||
|
||
day: int
|
||
order_id: tuple[str, ...]
|
||
# Пользователь магазина: тот же человек, что стоит за купившей кукой.
|
||
user_id: NDArray[np.uint64]
|
||
# Когда строка заказа создана в базе источника: секунда той самой покупки
|
||
# — в модели строка создаётся синхронно с ней — и миллисекунда часов базы.
|
||
# От этого момента отсчитываются и моменты судьбы.
|
||
created_at: NDArray[np.datetime64]
|
||
# Позиции заказа: номера товаров каталога и штуки, ячейка на заказ. У
|
||
# заказа с дельтой позиций на одну меньше, чем в корзине клиента.
|
||
product: tuple[NDArray[np.int64], ...]
|
||
quantity: tuple[NDArray[np.int64], ...]
|
||
# Деньги заказа, целые копейки.
|
||
items_total: NDArray[np.int64]
|
||
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
|
||
|
||
|
||
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)
|
||
created_at = _created_at(rng, purchases)
|
||
discount = _discount(purchases, items_total)
|
||
return Orders(
|
||
day=day,
|
||
order_id=purchases.order_id,
|
||
user_id=purchases.person_id,
|
||
created_at=created_at,
|
||
product=product,
|
||
quantity=quantity,
|
||
items_total=items_total,
|
||
discount=discount,
|
||
delivery=delivery,
|
||
total=items_total - discount + delivery,
|
||
outcome=outcome,
|
||
paid_after=paid_after,
|
||
cancelled_after=cancelled_after,
|
||
)
|
||
|
||
|
||
def window(day: int) -> range:
|
||
"""Дни рождения, чьи заказы несёт слепок дня `day`.
|
||
|
||
Окно изменяемости — константа мира; у начала оси оно усекается само, а не
|
||
сторожем: дней до D0 попросту нет.
|
||
"""
|
||
return range(max(0, day - world.ORDER_WINDOW_DAYS + 1), day + 1)
|
||
|
||
|
||
def at_boundary(rows: Orders, day: int) -> tuple[list[str], NDArray[np.datetime64]]:
|
||
"""Статус заказов и момент их последнего изменения на границе `day`|`day+1`.
|
||
|
||
Судьба решена при рождении, поэтому слепок её только читает: момент позже
|
||
границы для него ещё не случился. Отмена перевешивает оплату — у дороги
|
||
«оплачен и отменён» она поздняя, и заказ на границе уже отменён.
|
||
`updated_at` — поздний учтённый момент, а без единого заказ показывает своё
|
||
рождение: строку с тех пор никто не трогал.
|
||
"""
|
||
edge = _boundary(day)
|
||
paid = rows.created_at + rows.paid_after.astype("timedelta64[s]")
|
||
cancelled = rows.created_at + rows.cancelled_after.astype("timedelta64[s]")
|
||
# Момента, которого у исхода нет, в данных нет вовсе: там −1, и без маски
|
||
# он прикинулся бы моментом за секунду до рождения.
|
||
got_paid = (rows.paid_after >= 0) & (paid <= edge)
|
||
got_cancelled = (rows.cancelled_after >= 0) & (cancelled <= edge)
|
||
|
||
status = np.where(got_cancelled, "cancelled", np.where(got_paid, "paid", "created"))
|
||
updated = np.where(
|
||
got_cancelled, cancelled, np.where(got_paid, paid, rows.created_at)
|
||
)
|
||
return status.tolist(), updated
|
||
|
||
|
||
def _boundary(day: int) -> np.datetime64:
|
||
"""Граница суток `day`|`day+1` абсолютной меткой: полночь пояса счётчика.
|
||
|
||
Модельные сутки считаются в поясе счётчика, а моменты заказа — метки UTC:
|
||
между ними ровно смещение пояса.
|
||
"""
|
||
midnight = np.datetime64(world.ORIGIN, "s") + np.timedelta64(day + 1, "D")
|
||
return midnight - np.timedelta64(world.COUNTER_TIMEZONE_MINUTES, "m")
|
||
|
||
|
||
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 _created_at(
|
||
rng: np.random.Generator, purchases: Purchases
|
||
) -> NDArray[np.datetime64]:
|
||
"""Момент создания строки заказа: секунда покупки и миллисекунда часов базы.
|
||
|
||
Секунда приходит из события — строка создаётся синхронно с покупкой, и
|
||
сдвигать её значило бы подделывать аудит источника. Миллисекунду трекер не
|
||
видит вовсе: у него своё разрешение, у базы своё, и три дописанных нуля
|
||
выдали бы секундную модель за миллисекундную (исследование формата слепка).
|
||
"""
|
||
millisecond = rng.integers(0, 1000, len(purchases))
|
||
return purchases.moment.astype("datetime64[ms]") + millisecond.astype(
|
||
"timedelta64[ms]"
|
||
)
|
||
|
||
|
||
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, items_total: NDArray[np.int64]
|
||
) -> NDArray[np.int64]:
|
||
"""Скидка каждого заказа: процент промокода от оставшейся корзины, вниз.
|
||
|
||
Броска здесь нет: код выбрал посетитель, и он уже уехал в событие —
|
||
бэкенду остаётся прочитать таблицу. Заказ без кода скидки не получает,
|
||
а спорную копейку округление оставляет магазину. Склад сначала вычёркивает
|
||
отсутствующую позицию, поэтому скидка считается уже от `items_total`.
|
||
"""
|
||
percent = np.array(
|
||
[_DISCOUNT_PERCENT[code] if code else 0 for code in purchases.coupon],
|
||
dtype=np.int64,
|
||
)
|
||
return items_total * percent // 100
|