Исправить отрицательный total после складской дельты #108
@@ -56,37 +56,37 @@
|
||||
{
|
||||
"day": 0,
|
||||
"date": "2026-06-01",
|
||||
"sha256": "98736f45970f65e04256cd3c108a07332a7276769b7a5dbf8b5617b75116a3c3"
|
||||
"sha256": "6c3e3c1c8689ec23f53f0953db1b941e6f010e9e56a74d8d017f40195d3dec17"
|
||||
},
|
||||
{
|
||||
"day": 1,
|
||||
"date": "2026-06-02",
|
||||
"sha256": "141c593f939eb695b8b639d9f47cc92efb5de1aa2aeffbc5f3abac63fa603628"
|
||||
"sha256": "9acb523cfd47f3d97d55122025852982ba5a1d8362036308ce72a4d9fb767f43"
|
||||
},
|
||||
{
|
||||
"day": 2,
|
||||
"date": "2026-06-03",
|
||||
"sha256": "c6f594f03bf1bba119bf591d5232fe6ce8d3793effab64d2171208f267ff8cac"
|
||||
"sha256": "b81482491c4c0b874ea84d5f3f05144100c906dacc059c068b331df258b97172"
|
||||
},
|
||||
{
|
||||
"day": 3,
|
||||
"date": "2026-06-04",
|
||||
"sha256": "382b20303450bbb7928e1f118cf36cc005c6d6747a215715c5d5c8dce0822f95"
|
||||
"sha256": "9d1ab326c4bf8b78c9fcaf7015d8029f9ad4cff5558b89c730eb428d175cc9ee"
|
||||
},
|
||||
{
|
||||
"day": 4,
|
||||
"date": "2026-06-05",
|
||||
"sha256": "ef743bad81a87725a767be45bfb18dfc74c20173ba0da5c41ccf254640c5af76"
|
||||
"sha256": "0e30d0e192e121121003c0cc9aaa55e24924afb983caff15ab105705fbf2bd00"
|
||||
},
|
||||
{
|
||||
"day": 5,
|
||||
"date": "2026-06-06",
|
||||
"sha256": "1618cf0f9a6816a1e302e0518a067cd4c7ca295dd1b7f4660c73f77da5182dfe"
|
||||
"sha256": "9a2ad812fcff9a40a988f24f8aa4fc7a379873d173a95ddacd438592aed0bfb5"
|
||||
},
|
||||
{
|
||||
"day": 6,
|
||||
"date": "2026-06-07",
|
||||
"sha256": "4e32a4cc9a8cc997fbb4bdf4727c22eb3a763e0d262262eccfe6a3094a14d142"
|
||||
"sha256": "e1706a27ff55de489a505489148c7a959329d95ad6597d92d3f78505f67bb221"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -78,22 +78,26 @@
|
||||
|
||||
**Дельта суммы (C) — вычеркнутая позиция.** Товара не оказалось в наличии,
|
||||
позицию сняли: у заказа на одну позицию меньше, чем в клиентских массивах, а
|
||||
`items_total` меньше на её стоимость. Уменьшаются ровно на полную стоимость
|
||||
вычеркнутой строки и `items_total`, и `total`; скидка остаётся рассчитанной от
|
||||
исходной клиентской покупки и после складского вычёркивания не пересчитывается.
|
||||
Момента у неё нет — заказ приезжает
|
||||
урезанным во всех своих слепках: первый слепок снимается на границе суток,
|
||||
когда склад заказ уже собрал. Заказ из одной позиции дельты не получает —
|
||||
пустых заказов не бывает. Позиция выбирается равновероятно: корреляция со
|
||||
спросом на доле 1–2% статистически ненаблюдаема — менти платил бы за неё
|
||||
таблицей чисел мира, а увидеть не мог бы ничем. Доводы за вычёркивание:
|
||||
остаток — сотни рублей, он торчит в витрине сверки сам; расхождение
|
||||
объясняется сравнением позиций — разбором вложенного JSON и `ARRAY JOIN`,
|
||||
ровно тем навыком, ради которого позиции разбираются; история рассказывается
|
||||
словами без легенды про генератор. Отклонено: *переоценка позиции* и *другое
|
||||
количество* — дельта в десятки рублей, её надо захотеть заметить; *чистая
|
||||
дельта без истории* — тупик, объяснить нечем; *врёт клиент, а не бэкенд* —
|
||||
заказ у нас проекция той же корзины.
|
||||
`items_total` меньше на её полную стоимость. После вычёркивания деньги заказа
|
||||
считаются от оставшейся корзины по [общему
|
||||
правилу](snapshot.md#откуда-берётся-заказ), поэтому `total` остаётся
|
||||
неотрицательным по построению. Момента у дельты нет — заказ приезжает урезанным
|
||||
во всех своих слепках: первый слепок снимается на границе суток, когда склад
|
||||
заказ уже собрал. Заказ из одной позиции дельты не получает — пустых заказов
|
||||
не бывает. Позиция выбирается равновероятно: корреляция со спросом на доле
|
||||
1–2% статистически ненаблюдаема — менти платил бы за неё таблицей чисел мира,
|
||||
а увидеть не мог бы ничем. Доводы за вычёркивание: остаток — сотни рублей, он
|
||||
торчит в витрине сверки сам; расхождение объясняется сравнением позиций —
|
||||
разбором вложенного JSON и `ARRAY JOIN`, ровно тем навыком, ради которого
|
||||
позиции разбираются; история рассказывается словами без легенды про генератор.
|
||||
Отклонено: *сохранять скидку исходной корзины и отбирать только безопасные
|
||||
позиции* — отсутствие товара стало бы зависеть от купона и доставки, а выбор
|
||||
позиции перестал бы быть равновероятным; *разрешить отрицательный `total` или
|
||||
ослабить строгий приём* — ошибка источника превратилась бы в брак, который
|
||||
молча терпит хранилище; *переоценка позиции* и *другое количество* — дельта в
|
||||
десятки рублей, её надо захотеть заметить; *чистая дельта без истории* —
|
||||
тупик, объяснить нечем; *врёт клиент, а не бэкенд* — заказ у нас проекция той
|
||||
же корзины.
|
||||
|
||||
**Потеря события (B) — точечная.** Уходит строка `purchase`, просмотр
|
||||
`/confirmation` остаётся: события уезжают разными запросами, потерять один и
|
||||
|
||||
@@ -171,13 +171,12 @@ JSON-объектом с точным набором ключей: `order_id`, `
|
||||
опытов убраны. Числа, механика опытов и поведение ClickHouse, на которое всё это
|
||||
опирается, — [документ хранилища](../storage.md), «Что проверено».
|
||||
|
||||
Одно расхождение с ожиданием осталось, и оно снаружи приёма: восемь настоящих
|
||||
строк слепка получили класс `field_invalid` — все версии одного заказа с
|
||||
отрицательным `total`. Класс заслужен, граница верна, дефект в генераторе и
|
||||
заведён отдельным issue
|
||||
[#102](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/102).
|
||||
Пока он не починен, критерий «честный прогон дня даёт пустой `_errors`» на
|
||||
стартовом мире не выполняется.
|
||||
После исправления
|
||||
[#102](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/102)
|
||||
честный прогон дня снят на живом стенде 20 августа 2026 года: все 1694 строки
|
||||
слепка дня 7 приняты, `ods.order_snapshot_errors` осталась пустой. У заказа
|
||||
`20260608-0007`, который прежде приходил с отрицательным итогом, теперь
|
||||
`5490.00 − 823.50 + 299.00 = 4965.50`.
|
||||
|
||||
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
|
||||
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
|
||||
|
||||
@@ -32,11 +32,12 @@
|
||||
позиций, посчитанная торговой половиной, то же число, что уехало клиентским
|
||||
`purchaseRevenue`, но у заказа с вычеркнутой позицией он меньше на её полную
|
||||
стоимость ([судьба заказа](fate.md)); `discount` — процент промокода от
|
||||
исходной суммы клиентской покупки, округлённый вниз, и дельта его не
|
||||
пересчитывает;
|
||||
оставшегося `items_total`, округлённый вниз: склад сначала определяет, какие
|
||||
позиции есть в заказе, затем бэкенд считает скидку от итоговой корзины;
|
||||
`delivery` — бросок заказной стороны по таблице целых весов, единственные
|
||||
деньги заказа, которых нет ни в одном событии; `total` = `items_total` −
|
||||
`discount` + `delivery`. Отсюда и правило витрин «деньги считаем по
|
||||
`discount` + `delivery`. Скидка не превышает `items_total`, поэтому итог
|
||||
неотрицателен по построению. Отсюда и правило витрин «деньги считаем по
|
||||
бэкенду»: про скидку и доставку клиент не знает вовсе.
|
||||
|
||||
Отклонено: *выводить заказ разбором собственного вывода* (`purchaseID`, сырой
|
||||
@@ -102,6 +103,11 @@
|
||||
развилки
|
||||
[«Форма записи слепка на проводе»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/81)).
|
||||
|
||||
Отрицательные копейки канонический сериализатор отвергает через `ValueError`
|
||||
до сборки записи: [контракт строгого
|
||||
приёма](ingestion.md#граница-строгого-приёма) их не допускает, а появление
|
||||
такого значения означает ошибку арифметики источника.
|
||||
|
||||
Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка —
|
||||
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт
|
||||
его даром, а хешу слепка в описи нужен именно названный порядок.
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]):
|
||||
"""Миллисекунды — часы базы источника, а не три дописанных нуля.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user