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-строки известны, но заказа нет ни в одном проверенном источнике; сумма по действующему правилу невосстановима. |
| Защищённые | 2 | orderId пересекается с работающим intake; их намеренно не замещают, чтобы не задвоить живой возврат. |
| Всего пустых строк | 863 | 782 + 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 строк. Это четыре одновременно проверяемых утверждения:
- возврат действительно существует и его item/model-координата известна;
- отсутствие заказа проверено в названных источниках;
- действующая формула не может вычислить
amountбез заказа; - список конечен, поименован и защищён цифровым отпечатком.
Поэтому 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 заранее измеренных координат.
Инварианты
- Разбиение текущего снимка: 863 = 782 восстановимых + 79 acknowledged + 2 protected.
- В ledger ровно 79 entries; он не расширяется автоматически при каждом новом
order_failure. - Все entries ledger относятся к
mpId=6; mp8 не добавляется в список без отдельной проверки и reviewed-изменения контракта. amount=NULLозначает неизвестную каноническую сумму, а не доказанный ноль.refundAmountнельзя использовать как обход формулы.- Любая новая отсутствующая строка, не совпавшая с точной записью ledger, блокирует проекцию до расследования.
- Если позже появится заказ, это не повод вручную вписать сумму в старый факт:
нужно повторить recovery →
fact_orders→fact_returns→ supersession и сохранить provenance. - 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
test_ym_acknowledged_loss_ledger_is_finite_and_exactly_materialized— ledger конечен, exact-match материализуется, acknowledged projection сохраняетNULL AS amount.test_manifest_binds_the_named_ym_order_recovery_and_loss_ledger— ledger привязан кfact_orders/fact_returnsgraph через manifest, с expected count 79 и reviewed SHA.- test_check_ym_acknowledged_losses.py — все 79 подставных координат проходят; пропавшая запись даёт красный код и печатает свой legacyReturnId и returnId.
Полная проверка документации остаётся обязательной: bash scripts/docs/check-documentation.sh.
Известные ограничения
- Дата
observedAt=2026-08-04относится к доказательству отсутствия заказа; это не утверждение о невозможности восстановления из будущего источника. - 79 entries — item-level строки; число уникальных заголовков возврата меньше и не должно подменять ledger count.
- Пока сумма неизвестна, net-метрики не вычитают эти деньги. Это измеримый недовычет, который нужно показывать как coverage caveat, а не маскировать нулём.
- Защищённые 2 строки не являются acknowledged loss и не должны добавляться в JSON только для того, чтобы получить арифметическое совпадение.