feat(generator): добавлена команда слепков заказов

- Зачем:
  - задача #92 добавляет второй источник и показывает слепок как чистую функцию зерна и дня.
- Что:
  - добавлены окно, состояние на границе суток и канонические байты с настоящими миллисекундами.
  - добавлены команда snapshot, сдвиг D → D−1 и переиспользование проигранных дней.
  - опись дополнена хешами слепков и поведенческими тестами.
- Проверка:
  - в generator выполнены make lint, make typecheck и make test: 427 тестов.
This commit is contained in:
2026-08-18 17:12:49 +03:00
parent c11da3b252
commit 8e21381e13
11 changed files with 740 additions and 46 deletions
@@ -27,16 +27,25 @@
делает это одним вызовом на колонку, и на дне в полсотни тысяч событий разница
заметна. Обратная сторона — день лежит в памяти дважды; проигрыватель поэтому
и берёт его днями, а не горизонтом целиком.
**Второй контракт провода — слепок заказов** (мастер-спека, раздел 2). Он не
похож на событие: одиннадцать ключей вместо сорока семи, деньги строками, а
не числами, времена с миллисекундами. Общее у них одно, зато главное: байты
рождаются здесь и только здесь. Запись слепка — один словарь с вложенным
списком и один `orjson.dumps`.
"""
from collections.abc import Sequence
from datetime import timedelta
from typing import Any
import numpy as np
import orjson
from numpy.typing import NDArray
from clickstream_generator import schema
from clickstream_generator import catalog, schema, world
from clickstream_generator.day import Day
from clickstream_generator.orders import Orders, at_boundary
_ARRAY_PREFIX = "Array("
@@ -60,6 +69,78 @@ def events(day: Day, limit: int | None = None) -> list[bytes]:
]
def orders(window: Sequence[Orders], day: int) -> list[bytes]:
"""Канонические байты слепка дня `day`: по документу JSON на заказ.
`window` — заказы дней окна, от раннего дня к позднему: слепок несёт их
подряд, и порядок строк выходит порядком рождения заказов, он же
возрастание `order_id`. Какие это дни, решает `orders.window`.
Деньги уезжают строками с ровно двумя знаками, а не числами: у заказа они
станут `Decimal`, и дробь двоичного числа была бы потерей точности до
всякого разбора. Времена — метки UTC с миллисекундами; `snapshot_date`
одинакова во всей выгрузке — это дата дня, состояние которого снято.
"""
goods = catalog.catalog()
sku = goods.sku.tolist()
prices = [_money(price) for price in goods.price.tolist()]
snapshot_date = (world.ORIGIN + timedelta(days=day)).isoformat()
payloads = []
for rows in window:
status, updated = at_boundary(rows, day)
created_at = _moments(rows.created_at)
updated_at = _moments(updated)
user_id = rows.user_id.tolist()
items_total = rows.items_total.tolist()
discount = rows.discount.tolist()
delivery = rows.delivery.tolist()
total = rows.total.tolist()
for number, order_id in enumerate(rows.order_id):
payloads.append(
orjson.dumps(
{
"order_id": order_id,
"user_id": user_id[number],
"status": status[number],
"created_at": created_at[number],
"updated_at": updated_at[number],
"items_total": _money(items_total[number]),
"discount": _money(discount[number]),
"delivery": _money(delivery[number]),
"total": _money(total[number]),
"items": [
{"sku": sku[item], "qty": count, "price": prices[item]}
for item, count in zip(
rows.product[number].tolist(),
rows.quantity[number].tolist(),
strict=True,
)
],
"snapshot_date": snapshot_date,
}
)
)
return payloads
def _money(kopecks: int) -> str:
"""Копейки — строкой с ровно двумя знаками: `129990` → `1299.90`."""
return f"{kopecks // 100}.{kopecks % 100:02d}"
def _moments(values: NDArray[np.datetime64]) -> list[str]:
"""Метки времени — строками RFC 3339 в UTC с миллисекундами.
Три знака стоят всегда, в том числе `.000`: одинаковая длина дробной части
и одинаковая зона дают хронологическую сортировку простым сравнением строк,
а разбор в хранилище идёт по точному шаблону.
"""
ms: NDArray[np.datetime64] = values.astype("datetime64[ms]")
return np.datetime_as_string(ms, unit="ms", timezone="UTC").tolist()
def _values(column: schema.Column, values: NDArray[Any]) -> list[Any]:
"""Колонка питоновскими значениями — в той записи, в какой уедет на провод.