From 64bcd3d596e4b959372b8a638b70d6a7ca9959ec Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Thu, 20 Aug 2026 13:13:14 +0300 Subject: [PATCH] =?UTF-8?q?fix(generator):=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D1=80=D0=B0=D1=81=D1=87=D1=91?= =?UTF-8?q?=D1=82=20=D1=81=D0=BA=D0=B8=D0=B4=D0=BA=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=20=D1=81=D0=BA=D0=BB=D0=B0=D0=B4=D1=81=D0=BA?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=B4=D0=B5=D0=BB=D1=8C=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - слепок D7 содержал отрицательный итог заказа и попадал в брак ODS. - Что: - скидка пересчитана от корзины после удаления отсутствующей позиции. - сериализатор отклоняет отрицательные деньги через ValueError. - добавлены проверки восьми стартовых дней и обновлена опись мира. - Проверка: - make lint, make typecheck и make test в generator/. - живой world_next_day: 1694 строки приняты, таблица ошибок пуста. --- data/world-inventory.json | 14 ++-- docs/architecture/orders/fate.md | 36 +++++---- docs/architecture/orders/ingestion.md | 13 ++- docs/architecture/orders/snapshot.md | 12 ++- generator/src/clickstream_generator/orders.py | 24 +++--- .../src/clickstream_generator/serialize.py | 4 +- generator/tests/test_orders.py | 81 ++++++++++++------- generator/tests/test_snapshot.py | 11 +++ 8 files changed, 123 insertions(+), 72 deletions(-) diff --git a/data/world-inventory.json b/data/world-inventory.json index 1c8773e..80d5e67 100644 --- a/data/world-inventory.json +++ b/data/world-inventory.json @@ -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" } ] } diff --git a/docs/architecture/orders/fate.md b/docs/architecture/orders/fate.md index 553ea79..4a99c94 100644 --- a/docs/architecture/orders/fate.md +++ b/docs/architecture/orders/fate.md @@ -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` остаётся: события уезжают разными запросами, потерять один и diff --git a/docs/architecture/orders/ingestion.md b/docs/architecture/orders/ingestion.md index 7c3521e..d67efcc 100644 --- a/docs/architecture/orders/ingestion.md +++ b/docs/architecture/orders/ingestion.md @@ -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: одно прямое чтение приносит весь слепок дня, метаданные доставки доступны, diff --git a/docs/architecture/orders/snapshot.md b/docs/architecture/orders/snapshot.md index 0a74b0d..1fe1a3c 100644 --- a/docs/architecture/orders/snapshot.md +++ b/docs/architecture/orders/snapshot.md @@ -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`**. Детерминизм даёт его даром, а хешу слепка в описи нужен именно названный порядок. diff --git a/generator/src/clickstream_generator/orders.py b/generator/src/clickstream_generator/orders.py index 800e734..849112d 100644 --- a/generator/src/clickstream_generator/orders.py +++ b/generator/src/clickstream_generator/orders.py @@ -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 diff --git a/generator/src/clickstream_generator/serialize.py b/generator/src/clickstream_generator/serialize.py index 17dec5a..01bdff7 100644 --- a/generator/src/clickstream_generator/serialize.py +++ b/generator/src/clickstream_generator/serialize.py @@ -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}" diff --git a/generator/tests/test_orders.py b/generator/tests/test_orders.py index 91b7d1a..c9fec14 100644 --- a/generator/tests/test_orders.py +++ b/generator/tests/test_orders.py @@ -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): diff --git a/generator/tests/test_snapshot.py b/generator/tests/test_snapshot.py index 918b45c..a189ec3 100644 --- a/generator/tests/test_snapshot.py +++ b/generator/tests/test_snapshot.py @@ -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]): """Миллисекунды — часы базы источника, а не три дописанных нуля.