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

BR-005 — Оборачиваемость (turnover_28d)

Статус: активный · SETTLED (epic-05/01 + epic-05/07) Владелец: Anton / business Дата / сверка с кодом: 2026-07-14, next/spec-platform (повторный semantic review adapter + MetricSpec + exact active-scope replacement)

Порядок чтения: сначала DO-NOT-REGRESS.md (DNR-002, DNR-005), затем STATUS-SEMANTICS.md §2. Единая точка правды значений — marketplace-collector-v3/kernel/etl/canon.py. Этот файл описывает только turnover.


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

Оборачиваемость отвечает на вопрос «за сколько дней при текущем темпе уйдёт средний складской остаток». Меряется по реальным продажам (выкупам, realized) за вычетом возвратов, а НЕ по заказам/спросу — та же семантика «реально совершённого действия», что и у рейтинга (DNR-001), но это разные метрики с разной формулой (DNR-002).

Смысл числа: turnover_28d = 14 → среднего остатка хватает на 14 дней продаж. Чем меньше, тем быстрее оборот. Считается на скользящем окне 28 дней. Вся группа площадок (WB{1,2}, Ozon{3,4,5}, YM{6,8}, Lamoda{9}) схлопывается в одну строку под первичным mp. Если за окно не было чистых продаж (net ≤ 0) или нет остатка — оборачиваемость не определена (NULL), а не 0.


Машинное правило

  • Зеркало: metric-status-map.yamlmetric_semantics.turnover (status_set: realized, net_of_returns: true).
  • Значения статусов/групп: canon.REALIZED_STATUS, canon.PRIMARY_GROUPS.
  • Код-реализация: marketplace-collector-v3/kernel/etl/build_fact_turnover.py.

Формула (net-делитель, epic-05/07):

net_sales    = sales_28d − returns_qty
turnover_28d = ROUND(avg_stock_28d × 28 / net_sales, 2) если net_sales > 0 И avg_stock_28d > 0
turnover_28d = NULL иначе

Источники данных (окно [calc_date−27 .. calc_date], 28 дней)

ПолеИсточникАгрегатФильтр
sales_28dfact_ordersSUM(quantity)realized-статусы per sub-mp (_REALIZED_FILTER), order_date в окне
revenue_28dfact_ordersSUM(revenue)те же
days_with_salesfact_ordersCOUNT(DISTINCT order_date)те же
returns_qtyfact_returnsSUM(COALESCE(quantity,1))return_date в окне, amount >= 0
returns_amountfact_returnsSUM(amount)те же
avg_stock_28dfact_stocks_dailyAVG(дневной SUM(stock_available))snapshot_date в окне
days_with_stockfact_stocks_dailyCOUNT(*) дней с остаткомsnapshot_date в окне
in_stockfact_stocks_dailySUM(stock_available) на MAX(snapshot_date ≤ calc_date)последний срез, любой давности
storage_cost_28dfact_storage_costsSUM(amount)cost_date в окне

Все суб-mp собираются из (1,2,3,4,5,6,8,9) и схлопываются в первичный mp через _PRIMARY_CASE ({1:(1,2), 3:(3,4,5), 6:(6,8), 9:(9,)}). mp_id=7 (YM Express) в kernel отсутствует — не добавлять (STATUS-SEMANTICS §1).


Инварианты

  1. Realized-семантика продаж. sales_28d/revenue_28d — только по REALIZED_STATUS (WB=sale, Ozon=delivered, YM=DELIVERED, Lamoda=Delivered). Прежний Lamoda-only фильтр (mp_id<>9 OR status='Delivered') заменён единым каноном (epic-05/01). ⛔ Не возвращать demand.
  2. Net-of-returns в делителе. Делится на net_sales = sales_28d − returns_qty, НЕ на gross. При этом sales_28d в колонке остаётся GROSS, а returns_qty/returns_amount — отдельные колонки (DNR-005): net считается выражением, не «схлопывается» в колонки.
  3. NULL, а не 0. net_sales ≤ 0 (нет чистых продаж, в т.ч. возвратов ≥ продаж) или avg_stock_28d ≤ 0turnover_28d = NULL. Ноль означал бы «мгновенный оборот» — ложь.
  4. Окно ровно 28 дней: [calc_date−27 .. calc_date] включительно (start = calc_date − 27, _build_for_date). Множитель × 28 = длина окна.
  5. Схлопывание группы. Одна строка на (model_id, первичный mp_id, calc_date); в fact_turnover присутствуют только первичные mp {1,3,6,9} (проверено в БД 2026-07-03).
  6. return_date IS NULL не вычитается (вне BETWEEN) — DNR-005; дыра лечится на сборе, не в формуле.
  7. amount >= 0 у возвратов — стале-строки с отрицательным amount (историч. WB total_price<0) не вычитаются: вычесть отрицательное = завысить net (DNR-005).
  8. in_stockavg_stock_28d: in_stock — последний срез остатка любой давности (может быть старше окна), в формуле turnover не участвует; в делителе только avg_stock_28d.
  9. Идемпотентность и удаление stale-строк: _build_for_date внутри общей транзакции заменяет точный active-scope {1,3,6,9} выбранной даты; UNIQUE (model_id, mp_id, calc_date) остаётся защитой от дублей.

Код (next/spec-platform, semantic review 2026-07-14)

Стабильные якоря в marketplace-collector-v3/kernel/etl/build_fact_turnover.py:

  • _PRIMARY_CASE / _ALL_SUB_MP — суб-mp → первичная группа.
  • _realized_status_filter / _REALIZED_FILTER — статусы из canon.
  • _upsert_sql — net CASE, sales, returns, avg stock, last stock и storage cost.
  • _build_for_date — окно [calc_date−27 .. calc_date] и exact active-scope replacement.
  • specs/metrics/turnover.yaml — исполняемый typed contract и golden cases.

Golden tests

Baseline и фикстуры: golden-cases/BASELINE-2026-07.md (снят 2026-07-03; кейс 4 — turnover net, model 522 — покрыт; baseline только с ИЮНЯ 2026, steady-state, memory 83f0d3c2).

Обязательные кейсы:

  • G-T1 realized-семантика. Артикул с большим числом заказов, но малым числом выкупов → sales_28d учитывает только realized, оборачиваемость медленнее (число больше), чем при demand.
  • G-T2 net-делитель. Продажи 5, возвраты 1, avg_stock=36.75net_sales=4, turnover = 36.75×28/4 = 257.25 (см. пример ниже) — НЕ 36.75×28/5=205.8.
  • G-T3 NULL при net≤0. sales_28d ≤ returns_qtyturnover_28d IS NULL (не 0, не отрицательное).
  • G-T4 NULL при stock=0. Есть продажи, нет остатка → NULL.
  • G-T5 схлопывание группы. Ozon FBS+rFBS+FBO {3,4,5} → одна строка mp_id=3; в таблице нет строк mp_id ∈ {2,4,5,8}.

Примеры (live, БД s3, calc_date=2026-07-02)

Верификация net-формулы (model_id=522, mp_id=1 WB): sales_28d=5, returns_qty=1net_sales=4; avg_stock_28d=36.75. turnover_28d = ROUND(36.75 × 28 / 4, 2) = 257.25 ✓ — совпало со значением в БД.

Срез таблицы (только calc_date=2026-07-02): всего 7 927 строк; turnover_28d IS NULL — 7 079 (мало/нет чистых продаж или нет остатка → NULL по инварианту 3); строк с returns_qty>0 — 499; distinct mp_id = {1,3,6,9} (только первичные — инвариант 5). Вся таблица (2 даты, 2026-07-01 + 2026-07-02) = 15 851 строка, из них NULL — 13 454 — не путать со срезом за одну дату.


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

  • ⚠️ Легаси-хвост статусов в fact_orders (return/order/fbs_fbs, обрываются к 2026-05-20) искажает месячные агрегаты до июня → baseline/golden только с ИЮНЯ 2026 (STATUS-SEMANTICS §3.4, memory 83f0d3c2).
  • Покрытие сбора Ozon/YM — проверено, полное (posting-level 2026-07-03, COVERAGE-MATRIX §2): прежний сигнал «недосбор Ozon ~0.4× / YM» оказался артефактом сравнения (analytics-псевдострока Ozon + двойная классификация каналов YM), а НЕ дырой сбора. sales_28d не занижен из-за сбора. Если turnover выглядит странно — это формула/сезонность/остатки, не покрытие заказов.
  • ⚠️ mp2 (WB FBS) не имеет строк sale → realized mp2 = 0; вклад в группу WB даёт только mp1. Это не баг данных (STATUS-SEMANTICS §3.1).
  • ⚠️ Матчинг возврата к конкретной продаже не делается (accrual по return_date на уровне группа×окно) — v2/REMAINDER (DNR-005). Возврат без realized-продаж в окне turnover не занижает (net не уходит ниже нуля дважды — LEFT JOIN).
  • ⚠️ Расхождение с legacy mp_turnover_daily — санкционировано (переход demand→realized дал −60/70%): reconciliation НЕ должна объявлять это регрессом (DNR-008).
  • Актуальная формула avg_stock×28/net_sales зафиксирована одновременно в docstring, _upsert_sql, MetricSpec и formulas.yaml; изменение любого слоя требует нового semantic review.