Skip to content

REST API

Проект: ADOLF — управленческий учёт
Модуль: CFO / REST API
Версия: 0.1.1
Дата: Май 2026


1. Обзор

CFO REST API предоставляет HTTP-доступ к финансовой аналитике модуля управленческого учёта: P&L по категориям, брендам, маркетплейсам и SKU, а также ABC-анализ по вкладу в прибыль. API служит источником данных для веб-интерфейса CFO (вкладки P&L и ABC) и сторонних интеграций.

КомпонентФайлПроцессПорт
REST APIsrc/cfo/api/cfo-api (uvicorn)8000
Сервисный слойsrc/cfo/services/внутри API
База данныхPostgreSQL reputation (+ FDW в 1cexport)внешний5432

API покрывает 7 бизнес-эндпоинтов (4 P&L + ABC + ABC-экспорт в Excel + управление списком исключений) и системный /health. Источники данных — те же агрегаты, что и в CLI-отчётах (pnl_grouped, abc_analysis); фронтенд получает их в JSON или, для ABC, скачивает готовый .xlsx-файл.

Вне скоупа текущей версии: Loss Makers, тренды, AI-инсайты, кастомные отчёты, PDF-экспорт, Excel-экспорт P&L, аутентификация, фильтр по бренду в SKU, фильтр по классу в ABC, иерархия категорий.


2. Общие характеристики

ПараметрЗначение
ФреймворкFastAPI + Pydantic v2
ФорматJSON, UTF-8
Базовый путь/api/v1/cfo
Системный путь/health (без префикса)
HTTP-методыGET (чтение) и POST (управление списком исключений)
CORSAccess-Control-Allow-Origin из CFO_API_CORS_ORIGINS (CSV, дефолт *); allow_methods=["GET", "POST"]; allow_credentials=false
АутентификацияНет (первая итерация)
Формат датISO 8601 — YYYY-MM-DD
Денежные значенияfloat, рубли
МаржинальностьВзвешенная: Σ profit / Σ revenue × 100
СортировкаФиксирована в SQL, как правило net_profit DESC
Логирование запросовMiddleware cfo.api.access: METHOD path -> status (duration ms)
ДокументацияSwagger UI /docs, ReDoc /redoc, OpenAPI /openapi.json

3. Запуск и конфигурация

API запускается консольным скриптом cfo-api или напрямую через uvicorn:

bash
cfo-api
# либо
uvicorn cfo.api.main:app --host 0.0.0.0 --port 8000

Переменные окружения

ПеременнаяДефолтОписание
CFO_API_HOST0.0.0.0Хост uvicorn
CFO_API_PORT8000Порт uvicorn
CFO_API_CORS_ORIGINS*CSV списка разрешённых origin'ов
CFO_API_POOL_MIN1Минимальный размер asyncpg-пула
CFO_API_POOL_MAX10Максимальный размер asyncpg-пула
CFO_API_CACHE_TTL1800 (30 мин)TTL ResponseCache в секундах. Кеш ключуется по SHA-256 от SQL + params.
CFO_CONFIGconfig.yamlПуть к YAML-конфигу CFO
CFO_DB_NAMEreputationИмя БД (переопределяет конфиг)
CFO_EXCLUSIONS_PATH./config/exclusions.jsonПуть к файлу с глобальным списком исключений (см. §5.8–5.9)
DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_SSLПараметры PostgreSQL
DB_DSNПолный DSN; имеет приоритет над отдельными DB_*

При старте приложение:

  1. Загружает .env (без перезаписи существующих переменных).
  2. Читает конфиг CFO_CONFIG.
  3. Открывает asyncpg-пул к PostgreSQL reputation с настройками из CFO_API_POOL_*.
  4. Создаёт ResponseCache(ttl=CFO_API_CACHE_TTL) и связывает его с пулом — все SQL-результаты автоматически кешируются.
  5. Инициализирует ExclusionsStore(CFO_EXCLUSIONS_PATH) для глобальных исключений.
  6. Поднимает Jinja2-окружение для рендера SQL.

При остановке — закрывает пул соединений.


4. Период

Все бизнес-эндпоинты принимают одинаковый набор параметров периода. Период задаётся одним из трёх способов: пресетом, явным диапазоном или дефолтом (если ничего не передано).

Якорь всех пресетов — yesterday = today − 1 day, не today. Это сделано, чтобы избежать показа «полудня» (сегодняшний день почти всегда неполный по данным маркетплейсов). Все пресеты заканчиваются на yesterday включительно.

4.1 Параметры

ПараметрТипОписание
presetenum: yesterday | week | month | yearОдин из встроенных пресетов
fromdate (ISO YYYY-MM-DD)Начало диапазона, включительно
todate (ISO YYYY-MM-DD)Конец диапазона, включительно

4.2 Семантика пресетов

Конкретные даты приведены для якоря today = 2026-05-21 (yesterday = 2026-05-20).

ПресетСемантикаПример (today=2026-05-21)
yesterdayОдин день: с yesterday по yesterday2026-05-202026-05-20
weekПоследние 7 дней включая вчера: с yesterday − 6 по yesterday2026-05-142026-05-20
monthС 1-го числа текущего месяца по yesterday2026-05-012026-05-20
yearС 1 января текущего года по yesterday2026-01-012026-05-20

Edge case — 1-е число месяца / 1 января. Если yesterday попадает в предыдущий период:

  • На 1-м числе месяца month даёт полный прошлый месяц (2026-04-012026-04-30 для today=2026-05-01).
  • На 1 января year даёт полный прошлый год (2025-01-012025-12-31 для today=2026-01-01).

То есть пресет всегда возвращает осмысленный непустой период «то, что только что закончилось».

4.3 Дефолт

Если в запросе нет ни preset, ни from/to, применяется yesterday. Один день — минимальный воспроизводимый диапазон.

Дефолт нестабилен во времени: ответ за 21 мая покажет 20 мая, за 22 мая — 21 мая. Для воспроизводимых ответов передавайте явный диапазон from/to.

4.4 Границы дня

Внутренние SQL-фильтры используют half-open сравнение ts >= :date_from AND ts < :date_to::date + INTERVAL '1 day' для всех колонок типа TIMESTAMPTZ. Это означает:

  • from = to = 2026-05-20 ловит весь день целиком — все транзакции с 2026-05-20 00:00:00 включительно до 2026-05-21 00:00:00 исключительно (то есть конечная граница — полночь следующего дня).
  • Граница дня в часовом поясе сервера БД (reputation).

Подробности — для внутренних потребителей в docs/IMPLEMENTATION.md. Для интегратора важно одно: даты в from/to включительны с обеих сторон по календарному дню.

4.5 Матрица валидации

Все ошибки валидации периода возвращают HTTP 422. Тексты сообщений приведены побайтно как в API.

СитуацияКодdetail
preset и from/to переданы одновременно422Use either 'preset' OR 'from'/'to', not both
Передан только from или только to422'from' and 'to' must be provided together
from > to422'from' must be <= 'to'
Невалидный формат даты в from/to422Стандартный ответ FastAPI ValidationError
Неизвестное значение preset (например, старые quarter или prev_month)422Стандартный ответ FastAPI ValidationError

4.6 Примеры

bash
# Дефолт — вчера
GET /api/v1/cfo/pnl/category

# Явный пресет
GET /api/v1/cfo/pnl/category?preset=month

# Явный диапазон
GET /api/v1/cfo/pnl/category?from=2026-03-01&to=2026-03-31

# Конфликт → 422
GET /api/v1/cfo/pnl/category?preset=month&from=2026-03-01&to=2026-03-31

5. Эндпоинты

Сводная таблица

МетодПутьНазначение
GET/healthПроверка состояния сервиса
GET/api/v1/cfo/pnl/categoryP&L по категориям товаров
GET/api/v1/cfo/pnl/brandP&L по брендам
GET/api/v1/cfo/pnl/marketplaceP&L по маркетплейсам
GET/api/v1/cfo/pnl/skuP&L по SKU (с фильтрами и пагинацией)
GET/api/v1/cfo/abcABC-классификация SKU по вкладу в прибыль
GET/api/v1/cfo/abc/exportABC как Excel-файл (без пагинации)
GET/api/v1/cfo/exclusionsТекущий список глобальных исключений
POST/api/v1/cfo/exclusionsПолная замена списка исключений

Глобальные исключения применяются автоматически ко всем /pnl/*, /abc, /abc/export. Списком управляет фронт через /exclusions (см. §5.8–5.9). Никаких query-параметров для управления — список читается из общего серверного состояния на каждый запрос.


5.1 GET /health

Системный эндпоинт без префикса /api/v1/cfo. Используется для liveness-проверок и health checks.

Параметры: нет.

Ответ 200:

json
{ "status": "ok" }
КодСитуация
200Сервис запущен

5.2 GET /api/v1/cfo/pnl/category

P&L по категориям товаров за указанный период.

Параметры запроса: только параметры периода (см. §4).

Логика «мусорных» групп

Строки SQL-результата с category, равным одному из токенов:

  • — нерасклассифицировано —
  • Неопознанный Товар
  • null
  • пустая строка ""

— агрегируются в одну строку с category="Прочее". Числовые поля суммируются, маржи пересчитываются взвешенно от итоговых сумм. После объединения результат пересортируется по убыванию net_profit, чтобы сохранить порядок, который ожидает фронт.

Замечание о mp_expenses

Поле mp_expenses — это сумма всех расходов маркетплейса: commission + logistics + return_logistics + storage + penalties + acquiring + other_mp_expenses. Соответствует SQL-полю mp_expenses_total.

Поля строки data[]

ПолеТипОпц.Описание
categorystringнетНазвание категории; "Прочее" для агрегата нерасклассифицированных
revenuefloatнетВыручка, ₽
cogsfloatнетСебестоимость (Cost of Goods Sold), ₽
mp_expensesfloatнетРасходы маркетплейса, ₽
gross_profitfloatнетВаловая прибыль (revenue − cogs), ₽
net_profitfloatнетЧистая прибыль (gross_profit − mp_expenses), ₽
gross_margin_pctfloatнетВаловая маржа, % (взвешенная)
net_margin_pctfloatнетЧистая маржа, % (взвешенная)

Поля summary

ПолеТипОпц.Описание
rows_countintнетКоличество строк в data (после объединения «Прочее»)
revenue, cogs, mp_expenses, gross_profit, net_profitfloatнетИтоги по всем строкам
gross_margin_pct, net_margin_pctfloatнетМаржа от итогов (взвешенная)

Пример ответа 200

json
{
  "period": { "from": "2026-03-01", "to": "2026-03-31" },
  "data": [
    {
      "category": "Халаты домашние",
      "revenue": 21806396.0,
      "cogs": 6680016.0,
      "mp_expenses": 4033118.0,
      "gross_profit": 15126380.0,
      "net_profit": 11093262.0,
      "gross_margin_pct": 69.4,
      "net_margin_pct": 50.9
    },
    {
      "category": "Жилеты",
      "revenue": 2362270.0,
      "cogs": 676220.0,
      "mp_expenses": 452692.0,
      "gross_profit": 1686050.0,
      "net_profit": 1233358.0,
      "gross_margin_pct": 71.4,
      "net_margin_pct": 52.2
    },
    {
      "category": "Прочее",
      "revenue": 1200000.0,
      "cogs": 400000.0,
      "mp_expenses": 200000.0,
      "gross_profit": 800000.0,
      "net_profit": 600000.0,
      "gross_margin_pct": 66.7,
      "net_margin_pct": 50.0
    }
  ],
  "summary": {
    "rows_count": 28,
    "revenue": 130749653.0,
    "cogs": 28037917.0,
    "mp_expenses": 45082071.0,
    "gross_profit": 102711736.0,
    "net_profit": 57629665.0,
    "gross_margin_pct": 78.6,
    "net_margin_pct": 44.1
  }
}

Коды ответов

КодСитуация
200Успех (даже если data пустой)
422Невалидные параметры периода
500Внутренняя ошибка БД

5.3 GET /api/v1/cfo/pnl/brand

P&L по брендам за указанный период. Поведение полностью идентично /pnl/category, поле группировки — brand. Объединение «мусорных» брендов в строку "Прочее" применяется так же.

Параметры запроса: только параметры периода.

Поля строки data[]

ПолеТипОпц.Описание
brandstringнетНазвание бренда; "Прочее" для агрегата нерасклассифицированных
revenue, cogs, mp_expenses, gross_profit, net_profitfloatнетМетрики, ₽
gross_margin_pct, net_margin_pctfloatнетМаржа, % (взвешенная)

Поля summary — те же, что в /pnl/category.

Пример ответа 200

json
{
  "period": { "from": "2026-03-01", "to": "2026-03-31" },
  "data": [
    {
      "brand": "Ohana market",
      "revenue": 95300000.0,
      "cogs": 19800000.0,
      "mp_expenses": 33200000.0,
      "gross_profit": 75500000.0,
      "net_profit": 42300000.0,
      "gross_margin_pct": 79.2,
      "net_margin_pct": 44.4
    },
    {
      "brand": "Ohana kids",
      "revenue": 33800000.0,
      "cogs": 7900000.0,
      "mp_expenses": 11600000.0,
      "gross_profit": 25900000.0,
      "net_profit": 14300000.0,
      "gross_margin_pct": 76.6,
      "net_margin_pct": 42.3
    },
    {
      "brand": "Прочее",
      "revenue": 1649653.0,
      "cogs": 337917.0,
      "mp_expenses": 282071.0,
      "gross_profit": 1311736.0,
      "net_profit": 1029665.0,
      "gross_margin_pct": 79.5,
      "net_margin_pct": 62.4
    }
  ],
  "summary": {
    "rows_count": 3,
    "revenue": 130749653.0,
    "cogs": 28037917.0,
    "mp_expenses": 45082071.0,
    "gross_profit": 102711736.0,
    "net_profit": 57629665.0,
    "gross_margin_pct": 78.6,
    "net_margin_pct": 44.1
  }
}

Коды ответов

КодСитуация
200Успех
422Невалидные параметры периода
500Внутренняя ошибка БД

5.4 GET /api/v1/cfo/pnl/marketplace

P&L по маркетплейсам за указанный период.

Параметры запроса: только параметры периода.

Объединение «мусорных» групп НЕ применяется. Каждый маркетплейс — отдельная строка с машинным кодом.

Поля строки data[]

ПолеТипОпц.Описание
marketplacestring (enum)нетМашинный код: wb, ozon, ym
revenue, cogs, mp_expenses, gross_profit, net_profitfloatнетМетрики, ₽
gross_margin_pct, net_margin_pctfloatнетМаржа, % (взвешенная)

Поля summary — те же, что в /pnl/category.

Пример ответа 200

json
{
  "period": { "from": "2026-03-01", "to": "2026-03-31" },
  "data": [
    {
      "marketplace": "wb",
      "revenue": 89540000.0,
      "cogs": 19200000.0,
      "mp_expenses": 31100000.0,
      "gross_profit": 70340000.0,
      "net_profit": 39240000.0,
      "gross_margin_pct": 78.6,
      "net_margin_pct": 43.8
    },
    {
      "marketplace": "ozon",
      "revenue": 41209653.0,
      "cogs": -506.0,
      "mp_expenses": 13982071.0,
      "gross_profit": 41210159.0,
      "net_profit": 18389665.0,
      "gross_margin_pct": 100.0,
      "net_margin_pct": 44.6
    }
  ],
  "summary": {
    "rows_count": 2,
    "revenue": 130749653.0,
    "cogs": 28037917.0,
    "mp_expenses": 45082071.0,
    "gross_profit": 102711736.0,
    "net_profit": 57629665.0,
    "gross_margin_pct": 78.6,
    "net_margin_pct": 44.1
  }
}

Отрицательный cogs у Ozon в примере — реальный артефакт неполного маппинга затрат, известный баг данных. API возвращает значение «как есть», без подмены. Подробнее см. §9.

Коды ответов

КодСитуация
200Успех
422Невалидные параметры периода
500Внутренняя ошибка БД

5.5 GET /api/v1/cfo/pnl/sku

P&L по отдельным SKU с фильтрами и пагинацией. Сортировка фиксирована в SQL — net_profit DESC (переопределить нельзя).

Параметры запроса

К параметрам периода (см. §4) добавляются:

ПараметрТипДефолтДиапазонОписание
marketplacestring, regex ^(wb|ozon|ym)$Фильтр по коду маркетплейса
categorystringТочное имя категории
only_lossboolfalseТолько SKU с net_profit < 0
limitint1001…500Размер страницы
offsetint0≥ 0Смещение от начала

Важно: summary агрегируется по всему результату после применения фильтров, без учёта пагинации. pagination.total равен summary.rows_count и показывает реальный размер набора.

Структура ответа

ПолеТипОпц.Описание
periodPeriodOutнетЭхо разрешённого периода
filtersPnLSkuFiltersнетЭхо применённых фильтров
paginationPaginationнетtotal, limit, offset
dataPnLSkuRow[]нетСтроки текущей страницы
summaryPnLGroupSummaryнетИтоги по всему результату

Поля filters

ПолеТипОпц.Описание
marketplacestring | nullдаnull, если фильтр не передан
categorystring | nullдаnull, если фильтр не передан
only_lossboolнетfalse, если флаг не передан

Поля pagination

ПолеТипОпц.Описание
totalintнетОбщее количество строк после фильтрации
limitintнетРазмер страницы из запроса
offsetintнетСмещение из запроса

Поля строки data[]

ПолеТипОпц.Описание
skustringнетАртикул (код SKU)
sku_namestringнетНазвание товара
brandstringнетБренд
categorystringнетКатегория
marketplacesstring[]нетКоды маркетплейсов, на которых SKU продаётся
revenuefloatнетВыручка, ₽
cogsfloatнетСебестоимость, ₽
mp_expensesfloatнетРасходы маркетплейса, ₽
gross_profitfloatнетВаловая прибыль, ₽
net_profitfloatнетЧистая прибыль, ₽
gross_margin_pctfloatнетВаловая маржа, %
net_margin_pctfloatнетЧистая маржа, %
cost_sourcestring | nullдаИсточник стоимости: turns_90, balance_41, supplier_prices или null

Примеры запросов

bash
# Дефолт — за прошлый месяц, первые 100 SKU
GET /api/v1/cfo/pnl/sku

# Явный диапазон + Wildberries, страница 3
GET /api/v1/cfo/pnl/sku?from=2026-03-01&to=2026-03-31&marketplace=wb&limit=100&offset=200

# Только убыточные в категории «Жилеты»
GET /api/v1/cfo/pnl/sku?from=2026-03-01&to=2026-03-31&category=%D0%96%D0%B8%D0%BB%D0%B5%D1%82%D1%8B&only_loss=true&limit=50

Пример ответа 200

json
{
  "period": { "from": "2026-03-01", "to": "2026-03-31" },
  "filters": {
    "marketplace": null,
    "category": null,
    "only_loss": false
  },
  "pagination": {
    "total": 2911,
    "limit": 100,
    "offset": 0
  },
  "data": [
    {
      "sku": "65001",
      "sku_name": "65001 Жилет синтепоновый чёрный стёжка треугольник",
      "brand": "Ohana market",
      "category": "Жилеты",
      "marketplaces": ["wb"],
      "revenue": 2362270.0,
      "cogs": 676220.0,
      "mp_expenses": 452692.0,
      "gross_profit": 1686050.0,
      "net_profit": 1233358.0,
      "gross_margin_pct": 71.4,
      "net_margin_pct": 52.2,
      "cost_source": "turns_90"
    },
    {
      "sku": "65002",
      "sku_name": "65002 Жилет синтепоновый красный стёжка треугольник",
      "brand": "Ohana market",
      "category": "Жилеты",
      "marketplaces": ["ozon", "wb"],
      "revenue": 1500000.0,
      "cogs": 450000.0,
      "mp_expenses": 300000.0,
      "gross_profit": 1050000.0,
      "net_profit": 750000.0,
      "gross_margin_pct": 70.0,
      "net_margin_pct": 50.0,
      "cost_source": "balance_41"
    }
  ],
  "summary": {
    "rows_count": 2911,
    "revenue": 130749653.0,
    "cogs": 28037917.0,
    "mp_expenses": 45082071.0,
    "gross_profit": 102711736.0,
    "net_profit": 57629665.0,
    "gross_margin_pct": 78.6,
    "net_margin_pct": 44.1
  }
}

Коды ответов

КодСитуация
200Успех (даже если data пустой)
422Невалидный marketplace (не соответствует regex), limit вне диапазона 1…500, offset < 0, ошибка периода
500Внутренняя ошибка БД

5.6 GET /api/v1/cfo/abc

ABC-классификация SKU по вкладу в чистую прибыль. Класс D выделяется отдельно для убыточных позиций.

Параметры запроса

К параметрам периода (см. §4) добавляются:

ПараметрТипДефолтДиапазонОписание
abc_afloatиз конфига (config.algorithm.abc_thresholds.a, обычно 80)0…100Кумулятивный порог класса A, %
abc_bfloatиз конфига (config.algorithm.abc_thresholds.b, обычно 95)0…100Кумулятивный порог класса B, %
searchstring≤100 символовПодстрочный case-insensitive фильтр по полю article
limitint1001…3000Размер страницы
offsetint0≥ 0Смещение

Дополнительная валидация: abc_a < abc_b. При нарушении — 422 с текстом вида abc_a (95.0) must be < abc_b (80.0).

  • Сравнение: LOWER(article) LIKE '%LOWER(search)%' — без учёта регистра, по подстроке. Например, search=жилет найдёт SKU с артикулом Жилет123 и ABC-ЖИЛЕТ-01.
  • Поиск выполняется в Python после SQL-запроса — не передаётся в БД. Это значит:
    • Повторные запросы с разным search за один период не делают новый запрос к БД — берут из кеша. Поиск отвечает за миллисекунды после первого «прогрева» периода.
    • summary (статистика по классам A/B/C/D) всегда рассчитывается по полному отчёту, без учёта search. Это сделано специально: фронт видит «всего 2911 SKU», но в таблице — только подходящие под фильтр.
    • pagination.totalчисло найденных по search (≤ summary.total_sku).
    • rank, abc_class, cumulative_pct у каждой строки сохраняются такими же, как в полном отчёте без search (классификация рассчитывается в SQL до фильтрации).

Логика классификации

SQL-запрос ранжирует SKU по убыванию net_profit среди прибыльных, считает кумулятивную долю и присваивает класс:

КлассУсловие
Anet_profit > 0 и cumulative_pct ≤ abc_a
Bnet_profit > 0 и abc_a < cumulative_pct ≤ abc_b
Cnet_profit > 0 и abc_b < cumulative_pct ≤ 100
Dnet_profit ≤ 0 (убыточные SKU, отдельная группа)

Для класса D поля rank, cumulative_pct и share_pct всегда null — убытки не делятся на положительную базу и не входят в ранжирование.

Структура ответа

ПолеТипОпц.Описание
periodPeriodOutнетЭхо периода
thresholdsAbcThresholdsнетПрименённые пороги a и b
summaryAbcSummaryнетИтоги и статистика по классам
paginationPaginationнетtotal, limit, offset
dataAbcRow[]нетОтранжированные SKU (страница)

Поля thresholds

ПолеТипОпц.Описание
afloatнетПрименённый порог A, %
bfloatнетПрименённый порог B, %

Поля summary

ПолеТипОпц.Описание
total_skuintнетОбщее количество SKU в результате
positive_profitfloatнетΣ net_profit по классам A+B+C (база для share_pct)
net_profitfloatнетpositive_profit + Σ net_profit класса D (нетто)
classesdict[A|B|C|D, AbcClassStat]нетСтатистика по классам

Поля classes.{A,B,C,D} (AbcClassStat)

ПолеТипОпц.Описание
sku_countintнетКоличество SKU в классе
net_profitfloatнетΣ net_profit класса (для D — отрицательное)
share_pctfloat | nullдаДоля от positive_profit, %; null для класса D

Поля строки data[] (AbcRow)

ПолеТипОпц.Описание
rankint | nullдаНомер в ранжировании (1, 2, …); null для класса D
abc_classstring (enum)нетA, B, C или D
skustringнетАртикул
sku_namestringнетНазвание
brandstringнетБренд
categorystringнетКатегория
marketplacesstring[]нетКоды маркетплейсов
revenuefloatнетВыручка, ₽
net_profitfloatнетЧистая прибыль, ₽ (для D — отрицательная)
net_margin_pctfloatнетЧистая маржа, % (для D — отрицательная)
cumulative_pctfloat | nullдаКумулятивная доля прибыли с начала списка, %; null для класса D

Примеры запросов

bash
# Дефолт — пороги из конфига (80/95), за прошлый месяц
GET /api/v1/cfo/abc

# Кастомные пороги
GET /api/v1/cfo/abc?from=2026-03-01&to=2026-03-31&abc_a=70&abc_b=90

# Полный список (до 3000 строк)
GET /api/v1/cfo/abc?from=2026-03-01&to=2026-03-31&limit=3000

# Невалидно: a >= b → 422
GET /api/v1/cfo/abc?abc_a=95&abc_b=80

Пример ответа 200

json
{
  "period": { "from": "2026-03-01", "to": "2026-03-31" },
  "thresholds": { "a": 80.0, "b": 95.0 },
  "summary": {
    "total_sku": 2911,
    "positive_profit": 58064074.0,
    "net_profit": 57629665.0,
    "classes": {
      "A": { "sku_count": 315,  "net_profit": 46451259.0, "share_pct": 80.0 },
      "B": { "sku_count": 683,  "net_profit": 8709611.0,  "share_pct": 15.0 },
      "C": { "sku_count": 1589, "net_profit": 2903204.0,  "share_pct": 5.0 },
      "D": { "sku_count": 324,  "net_profit": -434409.0,  "share_pct": null }
    }
  },
  "pagination": { "total": 2911, "limit": 100, "offset": 0 },
  "data": [
    {
      "rank": 1,
      "abc_class": "A",
      "sku": "65001",
      "sku_name": "65001 Жилет синтепоновый чёрный стёжка треугольник",
      "brand": "Ohana market",
      "category": "Жилеты",
      "marketplaces": ["wb"],
      "revenue": 2362270.0,
      "net_profit": 1233358.0,
      "net_margin_pct": 52.2,
      "cumulative_pct": 2.1
    },
    {
      "rank": 316,
      "abc_class": "B",
      "sku": "70114",
      "sku_name": "70114 Толстовка детская синяя",
      "brand": "Ohana kids",
      "category": "Толстовки",
      "marketplaces": ["ozon", "wb"],
      "revenue": 312500.0,
      "net_profit": 64200.0,
      "net_margin_pct": 20.5,
      "cumulative_pct": 81.4
    },
    {
      "rank": 999,
      "abc_class": "C",
      "sku": "60044",
      "sku_name": "60044 Носки мужские чёрные",
      "brand": "Ohana market",
      "category": "Носки",
      "marketplaces": ["wb"],
      "revenue": 18900.0,
      "net_profit": 1840.0,
      "net_margin_pct": 9.7,
      "cumulative_pct": 96.2
    },
    {
      "rank": null,
      "abc_class": "D",
      "sku": "55301",
      "sku_name": "55301 Платье летнее цветочное",
      "brand": "Ohana market",
      "category": "Платья",
      "marketplaces": ["ozon"],
      "revenue": 42100.0,
      "net_profit": -8650.0,
      "net_margin_pct": -20.5,
      "cumulative_pct": null
    }
  ]
}

Коды ответов

КодСитуация
200Успех
422abc_a >= abc_b; limit вне диапазона 1…3000; offset < 0; search длиной > 100 символов; ошибка периода
500Внутренняя ошибка БД

5.7 GET /api/v1/cfo/abc/export

ABC-классификация в формате Excel-файла (.xlsx), без пагинации. Файл скачивается клиентом (браузером).

Параметры запроса

К параметрам периода (см. §4) добавляются те же, что у /abc за исключением limit/offset:

ПараметрТипДефолтДиапазонОписание
abc_afloatиз конфига0…100Кумулятивный порог класса A, %
abc_bfloatиз конфига0…100Кумулятивный порог класса B, %
searchstring≤100 символовПодстрочный фильтр по article

Ответ

200 OK, заголовки:

ЗаголовокЗначение
Content-Typeapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Content-Dispositionattachment; filename=abc_<from>_<to>.xlsx; filename*=UTF-8''<encoded>

Тело — бинарный .xlsx. Структура файла:

ABC-анализ
Период: 2026-03-01 — 2026-03-31

Пороги: A ≤ 80 %, B ≤ 95 %; D — чистая прибыль ≤ 0
Чистая прибыль = выручка − себестоимость − расходы маркетплейса (комиссия, логистика, хранение, штрафы, эквайринг)
Не учитываются: реклама (нет источника данных), налоги, операционные расходы юр. лиц
Всего SKU: 2911
A: 315 SKU (≤80% кумул. чистой прибыли), Σ 46 451 259 ₽
B: 683 SKU (80–95% кумул. чистой прибыли), Σ 8 709 611 ₽
C: 1589 SKU (>95% кумул. чистой прибыли), Σ 2 903 204 ₽
D: 324 SKU (убытки), Σ −434 409 ₽

[Колонки: Ранг | Класс | Артикул | Название | Бренд | Категория | МП | Выручка | Чистая | Маржа% | Кумул.%]
[~3000 строк данных]

Если в запросе передан search — в файл попадают только отфильтрованные строки, но диагностический блок остаётся по полному отчёту.

Примеры запросов

bash
# Полный отчёт за март 2026
GET /api/v1/cfo/abc/export?from=2026-03-01&to=2026-03-31

# С поиском
GET /api/v1/cfo/abc/export?from=2026-03-01&to=2026-03-31&search=65001

# Дефолт (за вчера, с дефолтными порогами)
GET /api/v1/cfo/abc/export

Коды ответов

КодСитуация
200Файл сформирован
422Те же причины, что у /abc
500Внутренняя ошибка БД или ошибка генерации Excel

5.8 GET /api/v1/cfo/exclusions

Возвращает текущий список глобальных исключений. Фронт обычно вызывает этот эндпоинт при загрузке страницы — чтобы показать пользователю, какие артикулы и категории сейчас исключены из расчётов.

Параметры: нет.

Структура ответа (ExclusionsPayload)

ПолеТипОпц.Описание
articlesstring[]нетСписок исключённых артикулов (точное совпадение, регистронезависимо)
categoriesstring[]нетСписок исключённых имён категорий (точное совпадение по имени)
updated_atdatetime (ISO 8601)нетВремя последнего обновления файла со списком (UTC + offset)

При первом запуске, если файл config/exclusions.json отсутствует, ответ — пустые массивы и updated_at равно времени старта сервера.

Пример ответа 200

json
{
  "articles": ["65001", "ABC123"],
  "categories": ["Аксессуары"],
  "updated_at": "2026-05-20T10:15:00+03:00"
}

Коды ответов

КодСитуация
200Успех (даже если списки пустые)
500Ошибка чтения файла (право доступа, повреждён JSON)

5.9 POST /api/v1/cfo/exclusions

Полная замена списка исключений (idempotent replace). Принимает желаемое новое состояние целиком и атомарно записывает его в файл. Фронт хранит локальное состояние «то, что пользователь видит сейчас» и при кнопке «Сохранить» отправляет весь список.

Параметры: нет.

Тело запроса (ExclusionsRequest)

Content-Type: application/json
ПолеТипОпц.Описание
articlesstring[]да (дефолт [])Полный новый список артикулов
categoriesstring[]да (дефолт [])Полный новый список категорий

Если поле опущено — оно становится пустым массивом, и соответствующая категория исключений очищается. Пустые строки и дубликаты (без учёта регистра + триминг) автоматически отбрасываются. Сортировка результата — case-insensitive алфавитная.

Поведение

  1. Тело валидируется. Оба поля опциональны — если поле отсутствует, оно трактуется как пустой массив (дефолт). То есть {"articles": ["65001"]} без categories корректен и эквивалентен {"articles": ["65001"], "categories": []}. Передача {} сбросит обе категории.
  2. Нормализуется (trim + удаление пустых + dedup case-insensitive + сортировка).
  3. Атомарно пишется в файл CFO_EXCLUSIONS_PATH через tempfile + os.replace.
  4. Сбрасывается весь ResponseCache (предыдущие закешированные ответы могли учитывать старый список — становятся невалидными).
  5. Возвращается новое состояние со свежим updated_at.

После POST все следующие запросы к /pnl/*, /abc, /abc/export автоматически применяют новый список — без перезапуска сервера и без задержки на TTL кеша.

Пример запроса

http
POST /api/v1/cfo/exclusions HTTP/1.1
Content-Type: application/json

{
  "articles": ["65001", "  ABC123  ", "abc123"],
  "categories": ["Аксессуары"]
}

Пример ответа 200

json
{
  "articles": ["65001", "ABC123"],
  "categories": ["Аксессуары"],
  "updated_at": "2026-05-21T14:32:11+03:00"
}

(Здесь " ABC123 " и "abc123" были сведены к одному "ABC123" нормализацией.)

Сценарий «снять все исключения»

http
POST /api/v1/cfo/exclusions HTTP/1.1
Content-Type: application/json

{ "articles": [], "categories": [] }

Коды ответов

КодСитуация
200Список заменён и сохранён
422Невалидный JSON; поля articles / categories отсутствуют или не массивы строк
500Ошибка записи файла (нет прав на запись, диск полон)

6. Модели ответов

Сводное описание JSON-моделей. Каждая модель — то, что фронтенд получает в ответе. Для удобства алиасы Python-полей (например, from_from) опущены: в JSON всегда используется внешнее имя from.

PeriodOut

ПолеТипОпц.Описание
fromdate (ISO)нетНачало периода, включительно
todate (ISO)нетКонец периода, включительно

PnLGroupMetrics (общий базовый набор полей P&L)

ПолеТипОпц.Описание
revenuefloatнетВыручка, ₽
cogsfloatнетСебестоимость, ₽
mp_expensesfloatнетРасходы маркетплейса, ₽
gross_profitfloatнетВаловая прибыль, ₽
net_profitfloatнетЧистая прибыль, ₽
gross_margin_pctfloatнетВаловая маржа, %
net_margin_pctfloatнетЧистая маржа, %

PnLCategoryRow / PnLBrandRow / PnLMarketplaceRow

Расширяют PnLGroupMetrics одним полем-меткой:

МодельПолеТипОписание
PnLCategoryRowcategorystringНазвание категории (или "Прочее")
PnLBrandRowbrandstringНазвание бренда (или "Прочее")
PnLMarketplaceRowmarketplacestringМашинный код: wb, ozon, ym

PnLSkuRow

Расширяет PnLGroupMetrics детализацией по товару:

ПолеТипОпц.Описание
skustringнетАртикул
sku_namestringнетНазвание товара
brandstringнетБренд
categorystringнетКатегория
marketplacesstring[]нетКоды маркетплейсов
cost_sourcestring | nullдаИсточник стоимости

PnLGroupSummary

Расширяет PnLGroupMetrics:

ПолеТипОпц.Описание
rows_countintнетОбщее количество строк в результате

PnLSkuFilters

ПолеТипОпц.Описание
marketplacestring | nullдаЭхо параметра
categorystring | nullдаЭхо параметра
only_lossboolнетЭхо параметра

Pagination

ПолеТипОпц.Описание
totalintнетОбщее количество строк
limitintнетРазмер страницы
offsetintнетСмещение

PnLByCategoryReport / PnLByBrandReport / PnLByMarketplaceReport

ПолеТипОпц.Описание
periodPeriodOutнетПериод отчёта
dataPnLCategoryRow[] / PnLBrandRow[] / PnLMarketplaceRow[]нетСтроки результата
summaryPnLGroupSummaryнетИтоги

PnLSkuReport

ПолеТипОпц.Описание
periodPeriodOutнетПериод отчёта
filtersPnLSkuFiltersнетПрименённые фильтры
paginationPaginationнетИнформация о странице
dataPnLSkuRow[]нетСтроки текущей страницы
summaryPnLGroupSummaryнетИтоги по всему результату (не по странице)

AbcThresholds

ПолеТипОпц.Описание
afloatнетПорог класса A, %
bfloatнетПорог класса B, %

AbcClassStat

ПолеТипОпц.Описание
sku_countintнетКоличество SKU в классе
net_profitfloatнетΣ net_profit класса
share_pctfloat | nullдаДоля от positive_profit, %; null для D

AbcSummary

ПолеТипОпц.Описание
total_skuintнетОбщее количество SKU
positive_profitfloatнетСумма прибыли A+B+C (база для долей)
net_profitfloatнетНетто-прибыль (A+B+C+D)
classesdict[string, AbcClassStat]нетКлючи A, B, C, D

AbcRow

ПолеТипОпц.Описание
rankint | nullдаРанг (null для D)
abc_classstring (enum)нетA, B, C, D
skustringнетАртикул
sku_namestringнетНазвание
brandstringнетБренд
categorystringнетКатегория
marketplacesstring[]нетКоды маркетплейсов
revenuefloatнетВыручка, ₽
net_profitfloatнетЧистая прибыль, ₽
net_margin_pctfloatнетЧистая маржа, %
cumulative_pctfloat | nullдаКумулятивная доля прибыли, %; null для D

AbcReport

ПолеТипОпц.Описание
periodPeriodOutнетПериод отчёта
thresholdsAbcThresholdsнетПрименённые пороги
summaryAbcSummaryнетИтоги и статистика по классам
paginationPaginationнетИнформация о странице
dataAbcRow[]нетОтранжированные SKU

ExclusionsRequest

Тело запроса для POST /api/v1/cfo/exclusions.

ПолеТипОпц.Описание
articlesstring[]да (дефолт [])Полный новый список артикулов
categoriesstring[]да (дефолт [])Полный новый список категорий

ExclusionsPayload

Ответ GET /api/v1/cfo/exclusions и POST /api/v1/cfo/exclusions.

ПолеТипОпц.Описание
articlesstring[]нетНормализованный список артикулов (trim, dedup case-insensitive, sort)
categoriesstring[]нетНормализованный список категорий
updated_atdatetime (ISO 8601 с TZ)нетВремя последней записи

7. Коды ответов

КодСитуация
200Успех (даже если data пустой массив)
422Ошибка валидации параметров запроса
500Внутренняя ошибка БД

Формат всех ошибок — JSON-объект с единственным полем detail:

json
{ "detail": "Human-readable message" }

Источники 422

Сервер использует два механизма генерации ответа 422:

  1. Стандартная валидация FastAPI / Pydantic — на типы query-параметров, длины, regex, обязательность полей. Возвращает детальный ValidationError (массив ошибок с loc/msg/type).
  2. Глобальный обработчик ValueError — любой raise ValueError(...) из сервисного слоя превращается в 422 {"detail": "<текст исключения>"}. Через него идут текстовые сообщения вроде Unknown period preset 'X'; expected one of (...) и abc_a (X) must be < abc_b (Y). Тексты — побайтно из кода (src/cfo/periods.py, src/cfo/services/abc_service.py).

Сообщения из period_params.py ниже также возвращаются как 422 {"detail": "..."} (но через прямой raise HTTPException, не через ValueError-handler).

Типичные сообщения 422

ЭндпоинтСообщение
ЛюбойUse either 'preset' OR 'from'/'to', not both
Любой'from' and 'to' must be provided together
Любой'from' must be <= 'to'
/abc, /abc/exportabc_a (95.0) must be < abc_b (80.0)
/abc, /abc/exportСтандартный ValidationError на search длиной > 100 символов
/pnl/skuСтандартный ValidationError FastAPI на marketplace (regex), limit (диапазон), offset (диапазон)
POST /exclusionsСтандартный ValidationError на отсутствующие поля или неверные типы в теле

500

json
{ "detail": "Internal database error" }

Полный стек ошибки БД пишется в логи (cfo.api.errors) и не возвращается клиенту.


8. Перечисления (Enums)

ПеречислениеЗначения
PresetNameyesterday, week, month, year
Marketplace (значения параметра ?marketplace)wb, ozon, ym
AbcClassA, B, C, D
cost_sourceturns_90, balance_41, supplier_prices, null

9. Особенности и подводные камни

  • marketplaces всегда массив строк, даже если SKU продаётся только на одном маркетплейсе: ["wb"]. Не строка с разделителем.
  • summary в /pnl/sku считается по всему результату, а не по странице. pagination.total равен summary.rows_count. Это нужно, чтобы фронт мог показывать общие итоги и одновременно листать страницы.
  • Объединение «Прочее» работает только в /pnl/category и /pnl/brand. В /pnl/marketplace и /pnl/sku — нет.
  • Отрицательный cogs у Ozon — известный артефакт неполного маппинга затрат в данных-источнике. API возвращает значение «как есть», без подмены или скрытия. Это ограничение источника, а не баг API; будет устранено в следующих итерациях ингеста.
  • Дефолт yesterday нестабилен во времени. Один и тот же URL без параметров будет давать разный день каждые сутки. Для воспроизводимых ответов передавайте from/to.
  • Якорь пресетов — yesterday, не today. Это сделано осознанно: сегодняшний день почти всегда неполный (синки маркетплейсов запаздывают на часы). Все пресеты заканчиваются на yesterday включительно.
  • Границы дня в SQL — half-open. from = to = 2026-05-20 ловит весь день целиком. Раньше тут был баг с BETWEEN (теряли всё после полуночи последнего дня) — исправлено.
  • Класс D в ABC не имеет ни ранга, ни доли, ни cumulative_pct — все три поля null. Убытки не входят в ранжирование «по вкладу в прибыль» и не делятся на положительную базу.
  • gross_margin_pct пересчитывается на стороне API. В SQL вычисляется только margin_pct = net_margin_pct; валовая маржа считается как gross_profit / revenue × 100. При revenue = 0 возвращается 0, а не null.
  • Сортировка /pnl/sku и /pnl/marketplace фиксирована: net_profit DESC. Передать произвольный sort параметр нельзя.
  • Лимит /abc — до 3000 SKU за запрос (для обозримых каталогов этого хватает на полный список одной страницей). У /pnl/sku лимит ниже — 500.
  • search в /abc работает через Python-фильтр поверх кеша, не через SQL. Это значит: после первого «прогрева» периода любой запрос с search за тот же период отвечает мгновенно. Также summary всегда отражает полный отчёт, а не отфильтрованный — это by design, чтобы у фронта была общая статистика рядом с поиском.
  • Класс и cumulative_pct при поиске не пересчитываются. SKU 65001 имеет один и тот же ранг и класс в /abc и в /abc?search=65001 — классификация рассчитывается на полном наборе.
  • Глобальные исключения применяются автоматически. Нет способа отключить их для отдельного запроса. Если нужно увидеть «полный» отчёт — почистите список через POST /exclusions с пустыми массивами.
  • POST /exclusions сбрасывает весь ResponseCache. Это плата за то, что следующие запросы видят свежие цифры сразу, без ожидания TTL. Если у вас активный фронт с большим трафиком — кеш заполнится снова за минуты.
  • CORS по дефолту разрешает все origin'ы (*), но без credentials. Для прод-окружения задавайте CFO_API_CORS_ORIGINS явным списком доменов. allow_methods теперь включает POST (для /exclusions).

10. Контрольные значения (за март 2026)

Набор чисел, по которым удобно сверить корректность интеграции при первом подключении. Получены прогоном за from=2026-03-01&to=2026-03-31 с пустым списком исключений.

ЭндпоинтКонтрольное значение
/pnl/categorysummary.revenue ≈ 130 749 653, summary.net_profit ≈ 57 629 665; в data присутствует строка "category": "Прочее"
/pnl/brandsummary те же; в data присутствует строка "brand": "Прочее"
/pnl/marketplaceДве строки: wb и ozon; у ozon cogs ≈ −506 (артефакт данных, см. §9)
/pnl/sku?limit=100pagination.total = 2911; первая строка sku="65001", net_profit ≈ 1 233 358
/abcsummary.total_sku = 2911; classes.A.sku_count = 315, B = 683, C = 1589, D = 324; summary.positive_profit ≈ 58 064 074; summary.net_profit ≈ 57 629 665
/abc/exportФайл ~1–2 MB, ровно 2911 строк данных + ~10 строк диагностической шапки
/exclusionsПри пустом конфиге: {"articles": [], "categories": [], "updated_at": "<время старта>"}

После фикса границ дня (см. §4.4) цифры за полные месяцы могут оказаться чуть больше, чем в предыдущих версиях этого документа — раньше терялся день 31 марта целиком. Если ваши интеграционные тесты захардкодили старые числа — они потребуют обновления.


11. Версионирование и совместимость

  • Текущая версия API: 0.1.1 (см. метаданные FastAPI на /openapi.json).
  • Префикс /api/v1/cfo зарезервирован под мажорную версию 1; в рамках v1 контракт может расширяться обратно совместимо (новые поля, новые эндпоинты), но не ломаться.
  • Стабильность 0.1.x: первая итерация. Имена и типы полей, перечисленные в этом документе, считаются контрактом и не будут меняться без выпуска v2. Однако возможны добавления (новые опциональные поля).

Что не реализовано в этой версии

ВозможностьСтатус
Аутентификация и ролиПланируется
Фильтр по бренду в /pnl/skuПланируется
Фильтр по классу в /abcПланируется
Произвольная сортировкаПланируется
Иерархия категорий в /pnl/categoryОбсуждается отдельно
Loss Makers (топ убыточных SKU)Не реализовано
Тренды и динамика по периодамНе реализовано
Аномалии и алертыНе реализовано
AI-инсайтыНе реализовано
Кастомные отчётыНе реализовано
Excel-экспорт P&L через APIНе реализовано (через API доступен только ABC; P&L — через CLI)
PDF-экспортНе реализовано

История изменений

ДатаЧто
Май 2026 (v0.1.0)Первая публичная версия: 4 P&L + ABC, пресеты week/month/quarter/prev_month, дефолт prev_month
Май 2026 (v0.1.1)Новые пресеты yesterday/week/month/year, дефолт yesterday, фикс границ дня в SQL, /abc?search=, /abc/export, /exclusions (GET/POST), CORS allow_methods=["GET","POST"]

Документ подготовлен: Май 2026
Версия: 0.1.1
Статус: Актуальный

Документация ADOLF Platform