Перейти к основному содержимому

BR-009 — Заявленные потери по возвратам Яндекс Маркета

Статус✅ SETTLED как конечный список исключений; это не означает, что сумма равна нулю
ВладелецAnton / business
Дата2026-08-04; ledger ym.returns.acknowledged_missing_orders.v1
СвязаноBR-003 — net-of-returns, F-44, recovery ym.returns.legacy_recovery
Машинный контрактym-acknowledged-missing-returns.json, манифест build-all-inputs.yaml

Человеческое правило

В gwptd_kernel.fact_returns есть возвраты Яндекс Маркета, для которых сам факт возврата известен, но каноническую денежную сумму вычислить нельзя: в системе нет соответствующего заказа. Такие строки не надо превращать в выдуманную сумму и не надо называть нулевым возвратом. Они входят в конечный именованный реестр acknowledged loss — заявленной потери данных.

На текущем снимке пустые YM-строки раскладываются так:

ГруппаСтрокЧто с ними происходит
Восстановимые782Есть доказуемый путь до заказа и per-unit выручки; после recovery и ETL сумма может быть рассчитана и пустая legacy-строка замещена.
Заявленная потеря79Точные возвратные item-строки известны, но заказа нет ни в одном проверенном источнике; сумма по действующему правилу невосстановима.
Защищённые2orderId пересекается с работающим intake; их намеренно не замещают, чтобы не задвоить живой возврат.
Всего пустых строк863782 + 79 + 2.

Число 782 здесь — остаток 861 − 79: reviewed supersession-контракт ожидает 861 обычное замещение и отдельно исключает 79 acknowledged-записей; ещё 2 строки защищены overlap-гейтом. Это не означает, что все 782 уже заполнены: это объём восстановимой очереди.

1. Что именно содержит реестр

Файл ym-acknowledged-missing-returns.json действительно содержит 79 entries, как и заявлено в его expectedEntryCount. Все entries относятся к mpId=6 (YM FBS). Это строки item-level ledger, а не число уникальных заголовков возврата: в них 70 уникальных returnId/orderId, а itemCount в сумме равен 79. Сам файл объясняет происхождение расхождения: 101 исторический item-capture свернут в 79 канонических latest-item записей.

Запись реестра — это точная координата, а не свободное правило «если заказа нет, то можно пропустить строку». В ней зафиксированы:

  • mpId, returnId и orderId;
  • номер позиции lineNo, shopSku и число возвращённых единиц itemCount;
  • modelId и исходный legacyReturnId.

Состав и отсутствие заказа измерены на дату observedAt=2026-08-04 в трёх источниках: gwptd_kernel.fact_orders, gwptd_intake.ym_stats_orders и lamoda_reports.mp_orders_daily. Поэтому реестр говорит не «Яндекс никогда не существовал», а «на момент проверки ни одна из систем не содержит нужного заказа, от которого можно получить каноническую цену единицы».

2. Почему эти 79 не восстанавливаются

YM-возврат сначала разбирается на return_json.items[]: из каждой позиции берутся shopSku и положительный count, затем shopSku резолвится в модель. После этого ETL ищет точный заказ той же площадки, того же order_id и той же модели; в SQL join также обязательны fact_orders.quantity > 0 и fact_orders.revenue IS NOT NULL (build_fact_returns.py:1438-1456).

Деньги считаются не из суммы в ответе API, а из доли выручки заказа:

amount = SUM((fact_orders.revenue / fact_orders.quantity) * items[].count)

Это непосредственно реализовано в build_fact_returns.py:1483-1503. На простом примере: заказ на 1 000 ₽ с количеством 2 и возвратом одной штуки даёт 500 ₽. Если строки заказа нет, неизвестен сам делитель quantity и денежная база revenue; арифметика не имеет входных данных.

Ветка для acknowledged-строк поэтому не пытается угадать сумму: при order_matches = 0 и точном совпадении с ledger она записывает NULL AS amount (build_fact_returns.py:1511-1537). При этом refundAmount из сохранённого raw_json нельзя молча подставить вместо формулы: это возвратная сумма на уровне ответа, а не доказанная per-model доля продажи. Такое замещение нарушило бы BR-003 и денежную базу YM.

Независимая проверка на срезе 2026-08-04 показывает ту же причину: 79 возвратов (101 item-позиция в исходных captures) имеют ноль найденных order_id во всех трёх источниках. Поэтому это не недостающий JOIN в одном слое и не случайный сбой резолвера; восстанавливать нечего, пока не появится новый доказанный источник заказа (разбор A1-3: данные и диагноз).

3. Чем 79 отличаются от остальных пустых строк

782 восстановимые

Для этой группы есть точная item-level связь с API-возвратом, моделью и заказом либо предусмотрен отдельный provenance-preserving recovery-путь, который может доставить недостающий заказ. После успешной проекции появляются return_date, amount, quantity и source_payload_id; затем старый legacy-placeholder замещается, а не остаётся рядом второй строкой. Гейт supersession требует 861 таких обычных mapping-строк и не разрешает применять замену при неполной проекции (supersede_ym_legacy_return_facts.py:48-51, projection_check).

79 заявленных потерь

Возврат и его модель известны, но нет fact_orders, поэтому нет канонической денежной основы. Их нельзя включить в 782 только потому, что в raw_json сохранился refundAmount: это означало бы поменять правило расчёта для исключений и скрыть отсутствие заказа.

2 защищённых строки

Для них найдено overlaps_working: тот же orderId уже присутствует в работающем gwptd_intake.ym_returns. Recovery не имеет права замещать такую строку историческим payload — это может создать дубль и разрушить provenance. Код классифицирует overlap как protected, а не как acknowledged loss (recover_ym_returns_from_legacy.py:132-147, supersede_ym_legacy_return_facts.py:257-274). Их пустота — временный защитный результат до штатного заполнения; это не доказанная потеря денег.

4. Что означает «заявленная потеря»

Это не новая сумма и не разрешение скрыть 79 строк. Это четыре одновременно проверяемых утверждения:

  1. возврат действительно существует и его item/model-координата известна;
  2. отсутствие заказа проверено в названных источниках;
  3. действующая формула не может вычислить amount без заказа;
  4. список конечен, поименован и защищён цифровым отпечатком.

Поэтому NULL честнее нуля. Ноль утверждал бы: «возврат имел нулевую сумму». NULL вместе с entry в ledger утверждает другое: «возврат был, но его сумма неизвестна в канонической денежной базе». В net-метрике эти 79 не уменьшают выручку на выдуманное число; одновременно система не создаёт ложного впечатления, что возврата не было. Неизвестный недовычет остаётся видимым как измеренный пробел, а не превращается в тихий ноль.

5. Машинное правило и контракт

Манифест объявляет ledger отдельным режимом acknowledged_loss_ledger с полнотой named_finite_allowlist_with_file_digest, путём, endpoint'ом, ожидаемым числом 79 и SHA-256 (build-all-inputs.yaml:347-354). Проверяющий loader:

  • сверяет SHA и точную схему JSON;
  • требует ровно 79 entries с обязательными полями;
  • запрещает дубликаты, пустые ключи и любой mpId, кроме 6;
  • сверяет поля источника с reviewed-контрактом и повторно проверяет длину файла (build_fact_returns.py:113-176, build_input_manifest.py:95-103).

Внутри проекции ledger подключается по всем координатам: mp_id, return_id, order_id, line_no, shop_sku, item_count, model_id. Только такой exact match может перейти в acknowledged_missing. Любая новая строка без заказа, которая не названа в ledger, остаётся order_failure; _require_ym_projection() останавливает ETL до persistent DML (build_fact_returns.py:1572-1592, build_fact_returns.py:1822-1878).

Это и есть граница между честным исключением и permissive fallback: список не говорит «любая проблема YM допустима», он разрешает только 79 заранее измеренных координат.

Инварианты

  1. Разбиение текущего снимка: 863 = 782 восстановимых + 79 acknowledged + 2 protected.
  2. В ledger ровно 79 entries; он не расширяется автоматически при каждом новом order_failure.
  3. Все entries ledger относятся к mpId=6; mp8 не добавляется в список без отдельной проверки и reviewed-изменения контракта.
  4. amount=NULL означает неизвестную каноническую сумму, а не доказанный ноль. refundAmount нельзя использовать как обход формулы.
  5. Любая новая отсутствующая строка, не совпавшая с точной записью ledger, блокирует проекцию до расследования.
  6. Если позже появится заказ, это не повод вручную вписать сумму в старый факт: нужно повторить recovery → fact_ordersfact_returns → supersession и сохранить provenance.
  7. Ledger описывает будущее состояние, а не сегодняшнее. Все 79 координат приходят по эндпоинту ym.returns.legacy_recovery, а он в приёме ещё не залит — замер 04.08: в gwptd_intake.raw_payload есть только wb.returns.legacy_recovery (129) и ym.stats_orders.legacy_recovery (51). Пока заливки нет, ни одна запись ledger не материализуется, и это не потеря: прямой гейт _require_ym_projection() при этом тоже никого не признаёт, потому что признавать нечего. Обратная проверка обязана различать «источника ещё нет» и «источник есть, а запись пропала» — см. F-104.

Как проверить, что список не разошёлся с реальностью

1. Проверить сам файл и отпечаток

jq '.expectedEntryCount, (.entries | length)' \
specs/kernel/ym-acknowledged-missing-returns.json
shasum -a 256 specs/kernel/ym-acknowledged-missing-returns.json

Ожидается 79, 79 и 484cda6ab4ee16dd8cb38845f18ced6907c425f92c98a683d62483b9a4f9491e. Расхождение — это finding: JSON не исправлять молча, потому что loader и манифест должны остановить сборку.

2. Прогнать контрактные тесты

python3 -m pytest \
marketplace-collector-v3/tests/test_fact_returns_ym_sql.py::test_ym_acknowledged_loss_ledger_is_finite_and_exactly_materialized \
marketplace-collector-v3/tests/test_build_input_manifest.py::test_manifest_binds_the_named_ym_order_recovery_and_loss_ledger -q

Они проверяют соответственно exact materialization в temporary table, NULL AS amount, точное число 79, endpoint, путь и SHA в build manifest.

3. Проверить живой снимок и отсутствие заказа

На S3 read-only сверяются все три координаты источника для каждой ledger-записи: fact_orders, ym_stats_orders и lamoda_reports.mp_orders_daily. Одновременно проверяется снимок исходных пустых legacy-фактов. Фильтр source_payload_id IS NULL здесь намеренный: он отделяет старые placeholders от новых recovery-фактов, которые могут иметь заполненный source_payload_id, но оставаться с amount=NULL по acknowledged-правилу:

SELECT mp_id, COUNT(*) AS empty_legacy_rows
FROM gwptd_kernel.fact_returns
WHERE mp_id IN (6, 8)
AND amount IS NULL
AND return_date IS NULL
AND source_payload_id IS NULL
GROUP BY mp_id;

Число 863 — снимок исходного placeholder-набора до восстановления, а не вечная константа: после замещения 782 строк в этом исходном наборе ожидаются 79 acknowledged и 2 protected. При повторном dry-run проверяются также overlaps_working, order_failures и acknowledged_missing. На успешной проекции должны выполняться инварианты expected_items = parsed_items и parsed_items = projected_items + acknowledged_missing; новая незаявленная потеря должна сделать ETL красным.

Если любой из 79 заказов найден позднее, либо обнаружилась новая строка с тем же классом отсутствия, это не исправляется редактированием числа в документации: пересматриваются evidence, entries и SHA в одном reviewed изменении. Так реестр остаётся измеренным списком, а не свалкой всех временных ошибок сбора.

Обратная проверка выполняется отдельным read-only скриптом marketplace-collector-v3/scripts/check_ym_acknowledged_losses.py и включена в morning_check.py. Она сравнивает все 79 координат с последней intake-версией того же возврата. Красный результат называет пропавшую запись, но не отменяет ночной ETL: естественное изменение исторических данных должно остановить review, а не молча оставить stale ledger или заблокировать публикацию ядра.

Golden tests

Полная проверка документации остаётся обязательной: bash scripts/docs/check-documentation.sh.

Известные ограничения

  • Дата observedAt=2026-08-04 относится к доказательству отсутствия заказа; это не утверждение о невозможности восстановления из будущего источника.
  • 79 entries — item-level строки; число уникальных заголовков возврата меньше и не должно подменять ledger count.
  • Пока сумма неизвестна, net-метрики не вычитают эти деньги. Это измеримый недовычет, который нужно показывать как coverage caveat, а не маскировать нулём.
  • Защищённые 2 строки не являются acknowledged loss и не должны добавляться в JSON только для того, чтобы получить арифметическое совпадение.