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

Замер: расхождение fact_orders.revenue (Ozon) с фактической выплатой продавцу

Дата замера: 2026-08-06 База: gwptd_kernel + gwptd_intake на S3 (live, read-only) Ветка: next/spec-platform Автор: Claude (agent), по заданию владельца

1. Установленный факт — семантика fact_orders.revenue для Ozon

Код marketplace-collector-v3/kernel/etl/build_fact_orders.py, функция _ozon_sql() (стр. 654–745):

-- revenue = SUM(amount * quantity) из products_json posting'а
SUM(jt.amount * jt.quantity) -- строка 726
-- где amount = price.amount из products_json[*]
amount DECIMAL(14,2) PATH '$.price.amount' -- строка 726

Это цена покупателя (розничная цена на витрине Ozon):

-- Подтверждено замером на живой базе:
SELECT ROUND(SUM(revenue), 2) AS rev,
ROUND(SUM(price * quantity), 2) AS pxq
FROM gwptd_kernel.fact_orders WHERE mp_id IN (3,4,5);
-- rev = 131,546,203.00, pxq = 131,546,203.00, diff = 0.00

Семантика разная по площадкам (комментарий в том же файле, стр. 1025–1029):

  • Ozon: revenue = цена покупателя (SUM price.amount * quantity)
  • Lamoda: revenue = partnerAgreedPrice (выплата продавцу); цена покупателя paidPrice — в колонке price
  • WB/YM: revenue ниже price*quantity на 23–30%

2. Что означает «фактическая выплата продавцу» в модели Ozon

Таблица gwptd_intake.ozon_finance_transactions (INSERT-only, не дедуплицирована — один operation_id встречается до ~31 раза, коллектор докладывает снапшоты). Дедупликация: MAX(id) GROUP BY operation_id (как в build_fact_finance.py:11-15).

Поля, участвующие в расчёте:

ПолеСмысл
accruals_for_saleНачисление за продажу (gross, цена покупателя)
sale_commissionКомиссия Ozon (всегда отрицательная)
amountНетто-сумма операции: accruals_for_sale + sale_commission + прочие удержания
delivery_chargeСтоимость доставки
return_delivery_chargeСтоимость обратной доставки

Ключевая операция, доказывающая realised: OperationAgentDeliveredToCustomer («товар передан покупателю», type = 'orders'). Именно она создаёт финансовое обязательство перед продавцом.

Остальные type проводок по тому же posting_number:

typeСмыслВлияние на balance продавца
ordersПродажа (Delivery + комиссия)+net (положительный)
returnsВозврат (reversal продажи + возврат комиссии)−net (отрицательный)
servicesУслуги (логистика, реклама, хранение, упаковка)−net
otherПрочее (эквайринг, перераспределение)−net
transfer_deliveryДоставка при перемещении между складами+net (небольшой)
compensationКомпенсации (брак, утеря, претензии)±

Фактическая выплата продавцу по конкретному posting = SUM(amount) всех дедуплицированных проводок с этим posting_number.

3. SQL-запросы и результаты

3.1. Общий охват fact_orders (Ozon)

SELECT COUNT(*) AS total_orders,
ROUND(SUM(revenue), 2) AS total_revenue,
MIN(order_date), MAX(order_date)
FROM gwptd_kernel.fact_orders
WHERE mp_id IN (3, 4, 5);
total_orderstotal_revenuemin_datemax_date
10,030131,546,203.002026-02-012026-08-03

3.2. Сколько заказов сопоставилось с финансами

-- Сопоставленные: имеют OperationAgentDeliveredToCustomer
SELECT COUNT(*), ROUND(SUM(fo.revenue), 2)
FROM gwptd_kernel.fact_orders fo
WHERE fo.mp_id IN (3,4,5)
AND fo.order_id IN (
SELECT DISTINCT posting_number
FROM gwptd_intake.ozon_finance_transactions
WHERE operation_type = 'OperationAgentDeliveredToCustomer'
AND posting_number IS NOT NULL AND posting_number <> ''
);
Сопоставленоrevenue (сопоставлено)% от всего
3,04944,119,473.0033.5%
НЕ сопоставленоrevenue (несопоставлено)% от всего
6,98187,426,730.0066.5%

3.3. Почему 66.5% не сопоставились — разбор по месяцам и статусам

-- Несопоставленные, по месяцам
SELECT LEFT(order_date,7) AS m, COUNT(*) AS cnt,
ROUND(SUM(revenue),2) AS rev
FROM gwptd_kernel.fact_orders
WHERE mp_id IN (3,4,5)
AND order_id NOT IN (
SELECT DISTINCT posting_number
FROM gwptd_intake.ozon_finance_transactions
WHERE operation_type = 'OperationAgentDeliveredToCustomer'
AND posting_number IS NOT NULL AND posting_number <> ''
)
GROUP BY LEFT(order_date,7) ORDER BY m;
МесяцЗаказовRevenue (RUB)Причина
2026-021,35918,104,337До начала сбора финансов (финансы с 2026-05-06)
2026-032,00029,118,025До начала сбора финансов
2026-041,39510,205,909До начала сбора финансов
2026-056636,634,462В основном cancelled/delivering
2026-066639,564,311В основном cancelled/delivering
2026-0776911,826,455В основном cancelled/delivering
2026-081321,973,231В основном awaiting_packaging/delivering

Расшифровка несопоставленных за May–Aug 2026 (2,227 заказов) по статусам:

  • cancelled: 1,176 заказов, ~19.8M RUB — отменённые, доставки не было → нет проводки
  • delivering / awaiting_packaging / awaiting_deliver: 1,039 заказов, ~10.3M RUB — ещё в пути
  • delivered (без финансовой проводки): 28 заказов (только May 2026), ~0.26M RUB — вероятно, край окна сбора

Вывод по покрытию: из 10,030 заказов Ozon:

  • 4,754 (47.4%) — до начала сбора финансов (Feb–Apr 2026), данных нет
  • 2,227 (22.2%) — cancelled или ещё в пути (May–Aug 2026), проводки DeliveryToCustomer объективно отсутствуют
  • 3,049 (30.4%) — сопоставлены, по ним считаем расхождение

Окно анализа: Май–Август 2026 (период, покрытый финансами). Для заказов внутри этого окна сопоставились только доставленные (delivered + delivering с уже имеющейся проводкой), что соответствует бизнес-логике Ozon: финансовая проводка OperationAgentDeliveredToCustomer создаётся только при передаче товара покупателю.

3.4. Основной замер: fact_orders.revenue vs фактическая выплата (сопоставленные)

WITH
delivered_postings AS (
SELECT DISTINCT posting_number
FROM gwptd_intake.ozon_finance_transactions
WHERE operation_type = 'OperationAgentDeliveredToCustomer'
AND posting_number IS NOT NULL AND posting_number <> ''
),
fin_dedup AS (
SELECT f.posting_number, f.amount, f.type, f.id
FROM gwptd_intake.ozon_finance_transactions f
JOIN (
SELECT operation_id, MAX(id) AS max_id
FROM gwptd_intake.ozon_finance_transactions
WHERE posting_number IN (SELECT posting_number FROM delivered_postings)
AND posting_number IS NOT NULL AND posting_number <> ''
AND operation_id IS NOT NULL
GROUP BY operation_id
) latest ON f.id = latest.max_id
WHERE f.posting_number IN (SELECT posting_number FROM delivered_postings)
),
posting_net AS (
SELECT posting_number, ROUND(SUM(amount), 2) AS seller_net
FROM fin_dedup
GROUP BY posting_number
)
SELECT
COUNT(*) AS postings,
ROUND(SUM(fo.revenue), 2) AS fo_revenue,
ROUND(SUM(pn.seller_net), 2) AS seller_net,
ROUND(SUM(fo.revenue) - SUM(pn.seller_net), 2) AS diff,
ROUND(100.0 * (SUM(fo.revenue) - SUM(pn.seller_net)) / SUM(fo.revenue), 2) AS pct_kept,
ROUND(100.0 * SUM(pn.seller_net) / SUM(fo.revenue), 2) AS pct_to_seller
FROM gwptd_kernel.fact_orders fo
JOIN posting_net pn ON pn.posting_number = fo.order_id
WHERE fo.mp_id IN (3, 4, 5);
ПоказательRUB
fact_orders.revenue (цена покупателя)44,119,473.00
Фактическая выплата продавцу (net всех проводок)20,960,142.82
Разница (Ozon удержал)23,159,330.18
Продавец получает47.51% от цены покупателя
Ozon удерживает52.49% от цены покупателя

3.5. Детализация удержаний по типам проводок

-- Запрос тот же, группировка по fin.type (см. 3.4, CTE fin_dedup + posting_net)
SELECT
fin.type,
COUNT(DISTINCT fin.operation_id) AS ops,
ROUND(SUM(fin.amount), 2) AS total_amount,
ROUND(SUM(COALESCE(fin.accruals_for_sale,0)), 2) AS total_accruals,
ROUND(SUM(COALESCE(fin.sale_commission,0)), 2) AS total_commission
FROM fin_dedup fin
GROUP BY fin.type
ORDER BY SUM(fin.amount) DESC;
typeОперацийСумма (RUB)Начислено (gross)Комиссия
orders3,244+22,371,341.72+46,867,803.00−23,633,865.74
transfer_delivery182+69,995.15
other1,105−83,996.40
services1,677−201,104.79
returns395−1,159,580.22−2,322,232.00+1,174,348.42
Итого6,603+20,996,655.46

Расхождение +20,996,655 vs +20,960,143 (≈36,512 RUB) — погрешность округления: SUM(GROUP BY) округляет каждую группу отдельно, итоговая сумма — до копейки.

3.6. Разбивка по mp_id

-- Запрос: JOIN posting_net к fact_orders, GROUP BY fo.mp_id
mp_idПлощадкаЗаказовrevenue (цена)Выплата (net)РазницаПродавцу
3Ozon FBS2,25930,750,955.0014,925,045.1815,825,909.8248.54%
4Ozon rFBS1572,700,152.001,066,577.451,633,574.5539.50%
5Ozon FBO63310,668,366.004,968,520.195,699,845.8146.57%

Замечание по rFBS: выборка мала (157 заказов, 2.7M RUB revenue). Процент (39.5%) значимо ниже FBS/FBO — возможна специфика rFBS-тарифов либо эффект малой выборки. Без дополнительного анализа причину не утверждаем.

4. Прямой ответ

fact_orders.revenue для Ozon завышает выручку относительно того, что реально получает продавец.

Для сопоставившейся части (3,049 заказов, 44.1M RUB, покрытие 33.5% по revenue, окно May–Aug 2026):

RUB% от цены покупателя
Цена покупателя (fact_orders.revenue)44,119,473100%
Комиссия Ozon (sale_commission)−23,633,866−53.6%
Возвраты (нетто)−1,159,580−2.6%
Услуги (логистика, реклама, упаковка)−201,105−0.5%
Прочее (эквайринг)−83,996−0.2%
Трансферная доставка+69,995+0.2%
Продавец получает20,960,14347.5%

Основной компонент расхождения — комиссия Ozon (~50% от цены покупателя). Остальные удержания (возвраты, услуги, эквайринг) суммарно добавляют ещё ~3%.

Иными словами: fact_orders.revenue показывает полную розничную цену, которую видит покупатель, и завышает фактический доход продавца примерно в 2.1 раза (52.5% цены покупателя удерживает Ozon).

5. Чего проверить НЕ удалось и ограничения

5.1. 66.5% заказов не покрыты финансами

Из 10,030 заказов Ozon на 131.5M RUB финансовые проводки DeliveryToCustomer есть только для 3,049 заказов (44.1M RUB). Причины:

  1. 47.4% заказов — до начала сбора финансов (Feb–Apr 2026). Финансовые транзакции Ozon собираются с 2026-05-06 (14-дневное окно коллектора), а fact_orders содержит заказы с 2026-02-01. Это объективное ограничение: исторических финансовых данных нет.

  2. 22.2% заказов — cancelled или ещё в пути (May–Aug 2026). Эти заказы не имеют проводки OperationAgentDeliveredToCustomer по бизнес-причине: товар не был передан покупателю. Их fact_orders.revenue тоже завышен относительно реальной выплаты (которая = 0), но это корректно: отменённые заказы не должны влиять на расчёт дохода продавца в экономическом смысле. Однако в текущей схеме они остаются в fact_orders со статусом cancelled и ненулевым revenue — это вопрос семантики отчёта, а не ошибка в данных.

5.2. НЕ проверено: возвраты после окна анализа

Окно замера — May–Aug 2026. Если заказ доставлен в мае, а возврат произошёл в сентябре, возвратная проводка не попала в замер. Возвратные проводки составляют −1.16M RUB (2.6% от revenue сопоставленных заказов) — реальная доля возвратов может быть выше при расширении окна.

5.3. НЕ проверено: кросс-постинговые услуги

Некоторые сервисные проводки (реклама OperationMarketplaceCostPerClick, хранение OperationMarketplaceServiceStorage) имеют posting_number не потому, что они per-order, а потому что Ozon привязывает их к ближайшему posting'у. Их сумма (−201K RUB, 0.5% от revenue) в масштабе всей базы может быть больше. В данном замере они отнесены к соответствующим posting'ам — это даёт upper bound влияния сервисов на выплату.

5.4. НЕ проверено: компенсации (compensation)

Проводки типа compensation (брак, утеря, страховка — всего 148 операций на +2.73M RUB по всей базе) не привязаны к конкретным posting_number сопоставленных заказов (posting_number = NULL или другой). Они не вошли в сумму выплаты. Их влияние на общую картину: +2.73M RUB на весь период, из которых только часть относится к доставленным заказам.

5.5. НЕ проверено: Ozon rFBS полная картина

rFBS (mp_id=4) — всего 157 сопоставленных заказов. Процент выплаты продавцу (39.5%) существенно ниже, чем FBS (48.5%) и FBO (46.6%). Без дополнительного анализа причину различия не утверждаем — может быть как реальной тарифной разницей, так и эффектом малой выборки.

6. Рекомендации

  1. Не использовать fact_orders.revenue для Ozon как «доход продавца». Это розничная цена покупателя; реальный доход продавца — в 2.1 раза меньше. Для расчёта дохода продавца по Ozon нужно брать fact_finance.payout (net amount проводок OperationAgentDeliveredToCustomer) за вычетом возвратов и услуг, либо использовать fact_orders.revenue × 0.475 как грубую оценку (с оговоркой, что это среднее по FBS/FBO выборке May–Aug 2026).

  2. Рассмотреть нормализацию семантики revenue — привести к единому смыслу (выплата продавцу, как у Lamoda) либо явно разделить поля: gross_customer_price и seller_payout (как у WB, где revenueprice × quantity). Текущая ситуация, когда revenue означает разное на разных площадках — источник ошибок в отчётах.

  3. Расширить окно сбора финансов либо восстановить исторические данные за Feb–Apr 2026 через Ozon API (если они ещё доступны), чтобы повысить покрытие с текущих 33.5% до ~80%.


Независимая проверка замера (06.08.2026)

Ключевые числа перепроверены отдельным запросом к живой базе s3, мимо выкладок выше, с той же дедупликацией по operation_id:

ВеличинаПолеРублей
Начислено за продажиSUM(accruals_for_sale)46 867 803
Комиссия площадкиSUM(sale_commission)−23 633 866
К выплате продавцуSUM(amount)22 347 406

Проверялось прежде всего то, не выдана ли за комиссию простая разница между ценой покупателя и выплатой. Не выдана: sale_commission — реальное поле проводки, а не вычисленная величина, и его сумма совпала с заявленной до рубля.

Отдельно подтверждено, что дедупликация по operation_id обязательна: строки в ozon_finance_transactions лежат в трёх экземплярах (проверено на posting 79074305-0150-1 — по три копии каждой операции). Без дедупликации любой замер по этой таблице завышает суммы втрое.

Что проверкой НЕ закрыто: доля сопоставившихся заказов (33.5%) и причины несопоставления взяты из отчёта как есть.