Merge pull request 'Исправить отрицательный total после складской дельты' (#108) from fix/102-negative-order-total into main

Reviewed-on: #108
This commit was merged in pull request #108.
This commit is contained in:
2026-08-20 13:17:36 +03:00
8 changed files with 123 additions and 72 deletions
+7 -7
View File
@@ -56,37 +56,37 @@
{ {
"day": 0, "day": 0,
"date": "2026-06-01", "date": "2026-06-01",
"sha256": "98736f45970f65e04256cd3c108a07332a7276769b7a5dbf8b5617b75116a3c3" "sha256": "6c3e3c1c8689ec23f53f0953db1b941e6f010e9e56a74d8d017f40195d3dec17"
}, },
{ {
"day": 1, "day": 1,
"date": "2026-06-02", "date": "2026-06-02",
"sha256": "141c593f939eb695b8b639d9f47cc92efb5de1aa2aeffbc5f3abac63fa603628" "sha256": "9acb523cfd47f3d97d55122025852982ba5a1d8362036308ce72a4d9fb767f43"
}, },
{ {
"day": 2, "day": 2,
"date": "2026-06-03", "date": "2026-06-03",
"sha256": "c6f594f03bf1bba119bf591d5232fe6ce8d3793effab64d2171208f267ff8cac" "sha256": "b81482491c4c0b874ea84d5f3f05144100c906dacc059c068b331df258b97172"
}, },
{ {
"day": 3, "day": 3,
"date": "2026-06-04", "date": "2026-06-04",
"sha256": "382b20303450bbb7928e1f118cf36cc005c6d6747a215715c5d5c8dce0822f95" "sha256": "9d1ab326c4bf8b78c9fcaf7015d8029f9ad4cff5558b89c730eb428d175cc9ee"
}, },
{ {
"day": 4, "day": 4,
"date": "2026-06-05", "date": "2026-06-05",
"sha256": "ef743bad81a87725a767be45bfb18dfc74c20173ba0da5c41ccf254640c5af76" "sha256": "0e30d0e192e121121003c0cc9aaa55e24924afb983caff15ab105705fbf2bd00"
}, },
{ {
"day": 5, "day": 5,
"date": "2026-06-06", "date": "2026-06-06",
"sha256": "1618cf0f9a6816a1e302e0518a067cd4c7ca295dd1b7f4660c73f77da5182dfe" "sha256": "9a2ad812fcff9a40a988f24f8aa4fc7a379873d173a95ddacd438592aed0bfb5"
}, },
{ {
"day": 6, "day": 6,
"date": "2026-06-07", "date": "2026-06-07",
"sha256": "4e32a4cc9a8cc997fbb4bdf4727c22eb3a763e0d262262eccfe6a3094a14d142" "sha256": "e1706a27ff55de489a505489148c7a959329d95ad6597d92d3f78505f67bb221"
} }
] ]
} }
+20 -16
View File
@@ -78,22 +78,26 @@
**Дельта суммы (C) — вычеркнутая позиция.** Товара не оказалось в наличии, **Дельта суммы (C) — вычеркнутая позиция.** Товара не оказалось в наличии,
позицию сняли: у заказа на одну позицию меньше, чем в клиентских массивах, а позицию сняли: у заказа на одну позицию меньше, чем в клиентских массивах, а
`items_total` меньше на её стоимость. Уменьшаются ровно на полную стоимость `items_total` меньше на её полную стоимость. После вычёркивания деньги заказа
вычеркнутой строки и `items_total`, и `total`; скидка остаётся рассчитанной от считаются от оставшейся корзины по [общему
исходной клиентской покупки и после складского вычёркивания не пересчитывается. правилу](snapshot.md#откуда-берётся-заказ), поэтому `total` остаётся
Момента у неё нет — заказ приезжает неотрицательным по построению. Момента у дельты нет — заказ приезжает урезанным
урезанным во всех своих слепках: первый слепок снимается на границе суток, во всех своих слепках: первый слепок снимается на границе суток, когда склад
когда склад заказ уже собрал. Заказ из одной позиции дельты не получает — заказ уже собрал. Заказ из одной позиции дельты не получает — пустых заказов
пустых заказов не бывает. Позиция выбирается равновероятно: корреляция со не бывает. Позиция выбирается равновероятно: корреляция со спросом на доле
спросом на доле 1–2% статистически ненаблюдаема — менти платил бы за неё 1–2% статистически ненаблюдаема — менти платил бы за неё таблицей чисел мира,
таблицей чисел мира, а увидеть не мог бы ничем. Доводы за вычёркивание: а увидеть не мог бы ничем. Доводы за вычёркивание: остаток — сотни рублей, он
остаток — сотни рублей, он торчит в витрине сверки сам; расхождение торчит в витрине сверки сам; расхождение объясняется сравнением позиций —
объясняется сравнением позиций — разбором вложенного JSON и `ARRAY JOIN`, разбором вложенного JSON и `ARRAY JOIN`, ровно тем навыком, ради которого
ровно тем навыком, ради которого позиции разбираются; история рассказывается позиции разбираются; история рассказывается словами без легенды про генератор.
словами без легенды про генератор. Отклонено: *переоценка позиции* и *другое Отклонено: *сохранять скидку исходной корзины и отбирать только безопасные
количество* — дельта в десятки рублей, её надо захотеть заметить; *чистая позиции* — отсутствие товара стало бы зависеть от купона и доставки, а выбор
дельта без истории* — тупик, объяснить нечем; *врёт клиент, а не бэкенд* позиции перестал бы быть равновероятным; *разрешить отрицательный `total` или
заказ у нас проекция той же корзины. ослабить строгий приём* — ошибка источника превратилась бы в брак, который
молча терпит хранилище; *переоценка позиции* и *другое количество* — дельта в
десятки рублей, её надо захотеть заметить; *чистая дельта без истории*
тупик, объяснить нечем; *врёт клиент, а не бэкенд* — заказ у нас проекция той
же корзины.
**Потеря события (B) — точечная.** Уходит строка `purchase`, просмотр **Потеря события (B) — точечная.** Уходит строка `purchase`, просмотр
`/confirmation` остаётся: события уезжают разными запросами, потерять один и `/confirmation` остаётся: события уезжают разными запросами, потерять один и
+6 -7
View File
@@ -171,13 +171,12 @@ JSON-объектом с точным набором ключей: `order_id`, `
опытов убраны. Числа, механика опытов и поведение ClickHouse, на которое всё это опытов убраны. Числа, механика опытов и поведение ClickHouse, на которое всё это
опирается, — [документ хранилища](../storage.md), «Что проверено». опирается, — [документ хранилища](../storage.md), «Что проверено».
Одно расхождение с ожиданием осталось, и оно снаружи приёма: восемь настоящих После исправления
строк слепка получили класс `field_invalid` — все версии одного заказа с [#102](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/102)
отрицательным `total`. Класс заслужен, граница верна, дефект в генераторе и честный прогон дня снят на живом стенде 20 августа 2026 года: все 1694 строки
заведён отдельным issue слепка дня 7 приняты, `ods.order_snapshot_errors` осталась пустой. У заказа
[#102](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/102). `20260608-0007`, который прежде приходил с отрицательным итогом, теперь
Пока он не починен, критерий «честный прогон дня даёт пустой `_errors`» на `5490.00 823.50 + 299.00 = 4965.50`.
стартовом мире не выполняется.
Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении Забор из Kafka в STG снят на живом стенде 18 августа 2026 года при исполнении
#93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны, #93: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны,
+9 -3
View File
@@ -32,11 +32,12 @@
позиций, посчитанная торговой половиной, то же число, что уехало клиентским позиций, посчитанная торговой половиной, то же число, что уехало клиентским
`purchaseRevenue`, но у заказа с вычеркнутой позицией он меньше на её полную `purchaseRevenue`, но у заказа с вычеркнутой позицией он меньше на её полную
стоимость ([судьба заказа](fate.md)); `discount` — процент промокода от стоимость ([судьба заказа](fate.md)); `discount` — процент промокода от
исходной суммы клиентской покупки, округлённый вниз, и дельта его не оставшегося `items_total`, округлённый вниз: склад сначала определяет, какие
пересчитывает; позиции есть в заказе, затем бэкенд считает скидку от итоговой корзины;
`delivery` — бросок заказной стороны по таблице целых весов, единственные `delivery` — бросок заказной стороны по таблице целых весов, единственные
деньги заказа, которых нет ни в одном событии; `total` = `items_total` деньги заказа, которых нет ни в одном событии; `total` = `items_total`
`discount` + `delivery`. Отсюда и правило витрин «деньги считаем по `discount` + `delivery`. Скидка не превышает `items_total`, поэтому итог
неотрицателен по построению. Отсюда и правило витрин «деньги считаем по
бэкенду»: про скидку и доставку клиент не знает вовсе. бэкенду»: про скидку и доставку клиент не знает вовсе.
Отклонено: *выводить заказ разбором собственного вывода* (`purchaseID`, сырой Отклонено: *выводить заказ разбором собственного вывода* (`purchaseID`, сырой
@@ -102,6 +103,11 @@
развилки развилки
[«Форма записи слепка на проводе»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/81)). [«Форма записи слепка на проводе»](https://git.dementev.space/ddmitry/clickstream-data-platform/issues/81)).
Отрицательные копейки канонический сериализатор отвергает через `ValueError`
до сборки записи: [контракт строгого
приёма](ingestion.md#граница-строгого-приёма) их не допускает, а появление
такого значения означает ошибку арифметики источника.
Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка — Сверх контракта здесь живёт одно правило: **порядок строк внутри слепка —
порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт порядок рождения заказов, он же возрастание `order_id`**. Детерминизм даёт
его даром, а хешу слепка в описи нужен именно названный порядок. его даром, а хешу слепка в описи нужен именно названный порядок.
+13 -11
View File
@@ -11,10 +11,11 @@
**Деньги целыми копейками**, как и везде в генераторе: `items_total` **Деньги целыми копейками**, как и везде в генераторе: `items_total`
сумма позиций, посчитанная торговой половиной (у клиента то же число зовётся сумма позиций, посчитанная торговой половиной (у клиента то же число зовётся
выручкой), за вычетом строки, которую унесла дельта; `discount` скидка по выручкой), за вычетом строки, которую унесла дельта; `discount` скидка по
промокоду события, по таблице «код скидка» из чисел мира; `delivery` промокоду события от оставшегося `items_total`, по таблице «код скидка» из
единственные деньги заказа, которых нет ни в одном событии; `total` чисел мира; `delivery` единственные деньги заказа, которых нет ни в одном
`items_total` `discount` + `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)) outcome, paid_after, cancelled_after = _fate(rng, len(purchases))
product, quantity, items_total = _delta(rng, purchases) product, quantity, items_total = _delta(rng, purchases)
created_at = _created_at(rng, purchases) created_at = _created_at(rng, purchases)
discount = _discount(purchases) discount = _discount(purchases, items_total)
return Orders( return Orders(
day=day, day=day,
order_id=purchases.order_id, order_id=purchases.order_id,
@@ -262,17 +263,18 @@ def _delta(
return tuple(product), tuple(quantity), items_total 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( percent = np.array(
[_DISCOUNT_PERCENT[code] if code else 0 for code in purchases.coupon], [_DISCOUNT_PERCENT[code] if code else 0 for code in purchases.coupon],
dtype=np.int64, 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: 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}" return f"{kopecks // 100}.{kopecks % 100:02d}"
+54 -27
View File
@@ -16,6 +16,7 @@ import pytest
from numpy.typing import NDArray from numpy.typing import NDArray
from clickstream_generator import catalog, commerce, day, orders, plan, schema, world 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 from clickstream_generator.seeds import CANONICAL_SEED, Component
WEEKDAY = 2 WEEKDAY = 2
@@ -34,9 +35,15 @@ def weekday() -> day.Day:
@pytest.fixture(scope="module") @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]]: 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 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) percent = dict(world.COUPONS)
events = purchases_of(weekday) discounted_delta = 0
codes = [cell[0] for cell in events["purchaseCoupon"]]
assert sum(1 for code in codes if code) > 10
for number, code in enumerate(codes): for today in start_world:
# Скидка берётся от клиентской выручки, и складская дельта её не money = today.orders
# пересчитывает. В событии выручка дробная, у заказа — копейки. events = purchases_of(today)
revenue = round(events["purchaseRevenue"][number][0] * commerce.KOPECKS) codes = [cell[0] for cell in events["purchaseCoupon"]]
expected = revenue * percent[code] // 100 if code else 0 for number, code in enumerate(codes):
assert weekday.orders.discount[number] == expected 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 weekday.orders.discount[number] < revenue 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): def test_the_money_of_start_world_orders_adds_up(start_world: list[day.Day]):
"""`total` = `items_total` `discount` + `delivery`, целыми копейками.""" """Деньги всех восьми стартовых дней целые, связные и неотрицательные."""
money = weekday.orders deliveries: set[int] = set()
assert np.array_equal(
money.total, money.items_total - money.discount + money.delivery for today in start_world:
) money = today.orders
for column in (money.items_total, money.discount, money.delivery, money.total): assert np.array_equal(
assert np.issubdtype(column.dtype, np.integer) money.total, money.items_total - money.discount + money.delivery
assert np.all(column >= 0) )
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} 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): 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 subprocess
import sys import sys
from contextlib import closing from contextlib import closing
from dataclasses import replace
from datetime import UTC, date, datetime, time, timedelta from datetime import UTC, date, datetime, time, timedelta
from decimal import Decimal 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"]] 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]): def test_the_moments_carry_real_milliseconds(days: list[day_module.Day]):
"""Миллисекунды — часы базы источника, а не три дописанных нуля. """Миллисекунды — часы базы источника, а не три дописанных нуля.