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.yaml→metric_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_28d | fact_orders | SUM(quantity) | realized-статусы per sub-mp (_REALIZED_FILTER), order_date в окне |
revenue_28d | fact_orders | SUM(revenue) | те же |
days_with_sales | fact_orders | COUNT(DISTINCT order_date) | те же |
returns_qty | fact_returns | SUM(COALESCE(quantity,1)) | return_date в окне, amount >= 0 |
returns_amount | fact_returns | SUM(amount) | те же |
avg_stock_28d | fact_stocks_daily | AVG(дневной SUM(stock_available)) | snapshot_date в окне |
days_with_stock | fact_stocks_daily | COUNT(*) дней с остатком | snapshot_date в окне |
in_stock | fact_stocks_daily | SUM(stock_available) на MAX(snapshot_date ≤ calc_date) | последний срез, любой давности |
storage_cost_28d | fact_storage_costs | SUM(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).
Инварианты
- 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. - Net-of-returns в делителе. Делится на
net_sales = sales_28d − returns_qty, НЕ на gross. При этомsales_28dв колонке остаётся GROSS, аreturns_qty/returns_amount— отдельные колонки (DNR-005): net считается выражением, не «схлопывается» в колонки. - NULL, а не 0.
net_sales ≤ 0(нет чистых продаж, в т.ч. возвратов ≥ продаж) илиavg_stock_28d ≤ 0→turnover_28d = NULL. Ноль означал бы «мгновенный оборот» — ложь. - Окно ровно 28 дней:
[calc_date−27 .. calc_date]включительно (start = calc_date − 27,_build_for_date). Множитель× 28= длина окна. - Схлопывание группы. Одна строка на
(model_id, первичный mp_id, calc_date); вfact_turnoverприсутствуют только первичные mp{1,3,6,9}(проверено в БД 2026-07-03). return_date IS NULLне вычитается (вне BETWEEN) — DNR-005; дыра лечится на сборе, не в формуле.amount >= 0у возвратов — стале-строки с отрицательным amount (историч. WBtotal_price<0) не вычитаются: вычесть отрицательное = завысить net (DNR-005).in_stock≠avg_stock_28d:in_stock— последний срез остатка любой давности (может быть старше окна), в формуле turnover не участвует; в делителе толькоavg_stock_28d.- Идемпотентность и удаление 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.75→net_sales=4,turnover = 36.75×28/4 = 257.25(см. пример ниже) — НЕ36.75×28/5=205.8. - G-T3 NULL при net≤0.
sales_28d ≤ returns_qty→turnover_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=1 → net_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, memory83f0d3c2). - ✅ Покрытие сбора 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.