# formulas.yaml — машинные правила формул GWPTD (rating, turnover, net-of-returns,
#                  cost_price, seasonality) для тестов/генерации и CI-инвариантов.
#
# ИСПОЛНЯЕМЫЙ КОНТРАКТ ФОРМУЛ:
#   - specs/metrics/{cost_price,rating,turnover}.yaml — versioned MetricSpec + typed AST
#     + protected golden cases;
#   - marketplace-collector-v3/spec_runtime/metric_spec.py — строгий loader/evaluator;
#   - явные SQL adapters остаются в build_fact_rating.py/build_fact_turnover.py;
#   - группы/статусы: marketplace-collector-v3/kernel/etl/canon.py.
# Этот файл — расширенное человекочитаемое зеркало (источники, caveats, решения).
# Тест грузит и его, и MetricSpec/adapter: расхождение docs/spec/code = БАГ.
#
# Rating/cost_price, turnover и item-level net_of_returns повторно семантически сверены
# с живым кодом 2026-07-14; rating/cost_price также прошли security review. Точный code hash
# хранится в docs/backend/annotations.json и проверяется документационным CI.

# ─────────────────────────────────────────────────────────────────────────────
# 1. КОНСТАНТЫ СЕБЕСТОИМОСТИ
#    build_fact_rating.py: LOGISTICS_MARKUP_USD, CUSTOMS_COEFFICIENT, FALLBACK_USD_RUB_RATE
# ─────────────────────────────────────────────────────────────────────────────
constants:
  logistics_markup_usd:   3.0     # build_fact_rating.LOGISTICS_MARKUP_USD (config/gwptd.php)
  customs_coefficient:    1.47    # build_fact_rating.CUSTOMS_COEFFICIENT
  fallback_usd_rub_rate:  96.0    # build_fact_rating.FALLBACK_USD_RUB_RATE (config gwptd.cbr_fallback_rate)
  turnover_window_days:   28      # build_fact_turnover: окно [calc_date-27 .. calc_date] = 28 дней
  cost_rounding_dp:       2       # ROUND(cost_price_rub, 2)
  rating_rounding_dp:     6       # ROUND(rating, 6)
  turnover_rounding_dp:   2       # ROUND(turnover_28d, 2)

# ─────────────────────────────────────────────────────────────────────────────
# 2. КУРС USD/RUB (DNR-006 — всегда ЖИВОЙ, НЕ средний/исторический)
# ─────────────────────────────────────────────────────────────────────────────
usd_rub_rate:
  etl_source_priority:           # build_fact_rating._fetch_cbr_rate
    - "--rate CLI override"      # build(rate=...) — приоритет 0; invalid/non-positive/non-finite = hard error
    - "env GWPTD_USD_RUB_RATE"   # invalid/non-positive/non-finite configured override = hard error, не тихий CBR fallback
    - "ЦБ РФ live: GWPTD_CBR_RATE_URL (cbr-xml-daily.ru/daily_json.js) Valute.USD.Value, killable POSIX child, total wall-clock deadline=5s, terminate/kill/reap on timeout"
    - "fallback 96.0"            # transport/HTTP/JSON/invalid CBR payload -> validated FALLBACK_USD_RUB_RATE
  data_api_source_priority:      # data_api.cost_price
    - "query override"
    - "env GWPTD_USD_RUB_RATE"   # если положительное число
    - "ЦБ РФ live: GWPTD_CBR_RATE_URL (cbr-xml-daily.ru/daily_json.js) Valute.USD.Value, total wall-clock deadline=5s"
    - "fallback 96.0"            # любой сбой fetch -> FALLBACK_USD_RUB_RATE
  data_api_transport: "exact HTTPS host allowlist; redirects validated before follow; one daemon single-flight; leader hard deadline 5s; followers immediate stale/fallback"
  data_api_cache: "CBR success 3600s; failure/fallback 60s; late timed-out result is discarded"
  data_api_provenance: [source, effective_at, cached]
  written_to: fact_rating.usd_rub_rate
  validation: "effective rate must be finite, >0 and fit DECIMAL(10,4); _build_for_month revalidates before SQL; SQL also requires rate>0 and raw cost>0"
  decision: DNR-006

# ─────────────────────────────────────────────────────────────────────────────
# 3. COST PRICE (себестоимость в рублях, на модель)
#    build_fact_rating._upsert_sql: ROUND((price_buy_usd + logi) * customs * rate, 2)
# ─────────────────────────────────────────────────────────────────────────────
cost_price:
  formula: "cost_price_rub = ROUND((price_buy_usd + logistics_markup_usd) * customs_coefficient * usd_rub_rate, 2)"
  inputs:
    price_buy_usd: "последний срез fact_supplier_stock_price.price_buy_usd на модель (MAX(snapshot_date), price_buy_usd>0)"
    logistics_markup_usd: 3.0
    customs_coefficient: 1.47
    usd_rub_rate: "см. usd_rub_rate.source_priority"
  skip_row_if: "cost_price <= 0 (price_buy_usd NULL/<=0 -> INNER JOIN cp отбрасывает модель)"
  example:                       # проверочный кейс для теста: (10+3)*1.47*96
    price_buy_usd: 10.0
    usd_rub_rate: 96.0
    expected: 1834.56            # 13 * 1.47 = 19.11; 19.11 * 96 = 1834.56 -> ROUND(,2)

# ─────────────────────────────────────────────────────────────────────────────
# 4. СЕЗОННОСТЬ (фикс. коэффициенты, база 2024) — build_fact_rating.SEASONALITY
#    Захардкожены как config/gwptd.php. Ключ = номер месяца (1..12).
# ─────────────────────────────────────────────────────────────────────────────
seasonality:
  base_year: 2024
  default_if_missing: 1.0        # SEASONALITY.get(month, 1.0)
  by_month:
    1:  1.04
    2:  0.85
    3:  1.20
    4:  0.86
    5:  0.70
    6:  0.66
    7:  0.66
    8:  0.82
    9:  0.97
    10: 0.96
    11: 1.00
    12: 2.28

# ─────────────────────────────────────────────────────────────────────────────
# 5. RATING (рейтинг: модель × первичный mp × месяц) — build_fact_rating.py
#    DNR-001 (realized net), DNR-003 (exposure в знаменателе), DNR-004 (Ozon одно число),
#    DNR-005 (net-of-returns выражением).
# ─────────────────────────────────────────────────────────────────────────────
rating:
  grain: [model_id, mp_id, month]        # mp_id = ПЕРВИЧНЫЙ mp группы {1,3,6,9}
  upsert_key: [model_id, mp_id, month]   # UNIQUE; active mp scope exact DELETE+rebuild в одной транзакции
  status_set: realized                   # DNR-001: ТОЛЬКО реальные продажи, НЕ заказы/спрос
  net_of_returns: true                   # DNR-005
  # Числитель и знаменатель — на ОДНОМ круге mp (sales_group группы), epic-05/04.
  formula:
    net_qty:     "qty_sold - returns_qty"
    net_revenue: "revenue - returns_amount"        # WB revenue=for_pay, та же база что returns_amount
    # SQL-adapter использует неокруглённое landed-cost expression; отдельная
    # колонка cost_price_rub округляется до 2 знаков. Это зафиксировано честно,
    # чтобы будущая унификация округления была owner-gated, а не молчаливой.
    profit_raw:  "net_revenue - cost_price_raw_rub * net_qty"
    profit:      "ROUND(profit_raw, 2)"   # сохраняемая колонка; допускает отрицательный
    rating:      "ROUND(profit_raw / (exposure_days * cost_price_raw_rub * seasonality), 6)"
  components:
    revenue:        "SUM(fact_orders.revenue)  по sales_group за месяц, realized-статус (GROSS)"
    qty_sold:       "SUM(fact_orders.quantity) по sales_group за месяц, realized-статус (GROSS)"
    returns_qty:    "SUM(COALESCE(fact_returns.quantity,1)) по sales_group, return_date в месяце, amount>=0"
    returns_amount: "SUM(fact_returns.amount)   по sales_group, return_date в месяце, amount>=0"
    exposure_days:  "COUNT(DISTINCT fact_stocks_daily.snapshot_date) с stock_total>0 по ВСЕЙ sales_group за месяц"
    cost_price_raw_rub: "неокруглённое (price_buy_usd+3)*1.47*rate в profit/rating adapter"
    cost_price_rub: "ROUND(cost_price_raw_rub,2), сохраняется в fact_rating"
    seasonality:    "seasonality.by_month[month] (default 1.0)"
  skip_row_if:                            # legacy-паритет: модель НЕ пишется
    - "cost_price <= 0"                   # INNER JOIN cp
    - "exposure_days <= 0"                # JOIN ex + WHERE ex.exposure_days > 0
  returns_caveats:
    - "return_date IS NULL -> вне BETWEEN -> НЕ вычитается (DNR-005; аларм build_fact_returns только на НОВЫЕ NULL-даты source_payload_id IS NOT NULL; legacy source_payload_id IS NULL списан владельцем 2026-07-03, F-44)"
    - "amount < 0 (стале WB, устаревший intake) -> строка НЕ идёт в вычет (недо-вычет лучше пере-вычета)"
    - "возврат месяца M вычитается из продаж месяца M (accrual, НЕ матчинг к исходной продаже)"
    - "возврат на модель без realized-продаж в месяце: строки s нет -> не вычитается (v2 остаток)"
  # sales_group + realized-статус per первичный mp (из canon, epic-05/08):
  mp_spec:                                # _RATING_MP_SPEC (saved_mp, sales_group, status)
    1: { sales_group: [1, 2],    status: sale,      note: "WB — for_pay после комиссии" }
    3: { sales_group: [3, 4, 5], status: delivered, note: "Ozon — одно число (DNR-004)" }
    6: { sales_group: [6, 8],    status: DELIVERED, note: "YM — mp_id=7 отсутствует в kernel" }
    9: { sales_group: [9],       status: Delivered, note: "Lamoda" }
  history_note: "исторические строки fact_rating mp_id=5 НЕ удаляются, новые не плодятся (DNR-004)"
  historic_write_guard:
    normal: "build_all skips months older than previous month; explicit old month exits non-zero before write"
    override: "exactly one explicit month + explicit USD/RUB rate + V3_ALLOW_HISTORIC_RATING=1 + non-empty actor/reason; wrapper upgrades its inherited codebase-lock OFD SH->EX before exec, bootstrap proves and downgrades EX->SH with an independent probe before importing rating code; Python owns exclusive month lock"
    audit: "append-only rating_history_override_log: start commits independently; claim + fact replacement + success commit atomically; failure is appended best-effort after rollback; start/claim carry NULL result/error and NULL terminal_slot; binds rate/source, runtime commit and formula digest"
    orphan_recovery: "under the exclusive month lock, start without claim/terminal is closed as failure(OrphanedStartRecovered); claim without terminal fails closed for owner review"
    decision: DNR-006

# ─────────────────────────────────────────────────────────────────────────────
# 6. TURNOVER (оборачиваемость, 28-дн скользящее окно) — build_fact_turnover.py
#    DNR-002 (иная семантика чем rating — не рассинхрон), DNR-005 (net-of-returns).
# ─────────────────────────────────────────────────────────────────────────────
turnover:
  grain: [model_id, mp_id, calc_date]    # mp_id = первичный mp; вся группа -> одна строка
  upsert_key: [model_id, mp_id, calc_date]
  window: "[calc_date-27 .. calc_date] = 28 дней"
  status_set: realized                   # epic-05/01: продано per sub-mp, НЕ спрос
  net_of_returns: true                   # DNR-005
  formula:
    net_sales:    "sales_28d - returns_qty"
    turnover_28d: "ROUND(avg_stock_28d * 28 / net_sales, 2)"
  null_if:                               # turnover_28d = NULL когда
    - "net_sales <= 0"                   # (sales_28d - returns_qty) <= 0
    - "avg_stock_28d <= 0"
  components:
    sales_28d:        "SUM(fact_orders.quantity) по группе за окно, realized-фильтр (GROSS)"
    revenue_28d:      "SUM(fact_orders.revenue)  по группе за окно, realized-фильтр (GROSS)"
    returns_qty:      "SUM(COALESCE(fact_returns.quantity,1)) по группе, return_date в окне, amount>=0"
    returns_amount:   "SUM(fact_returns.amount)  по группе, return_date в окне, amount>=0"
    avg_stock_28d:    "AVG(дневной SUM(fact_stocks_daily.stock_available)) по группе за окно"
    days_with_sales:  "COUNT(DISTINCT fact_orders.order_date) с продажами (realized)"
    days_with_stock:  "COUNT дней с остатком в окне"
    in_stock:         "последний срез SUM(stock_available) (любая дата <= calc_date)"
    storage_cost_28d: "SUM(fact_storage_costs.amount) по группе за окно"
  note: "avg_stock из stock_available (turnover), НЕ stock_total (rating exposure) — разные поля намеренно"

# ─────────────────────────────────────────────────────────────────────────────
# 7. NET-OF-RETURNS (общий принцип вычета возвратов) — DNR-005
# ─────────────────────────────────────────────────────────────────────────────
net_of_returns:
  method: "gross-колонки СОХРАНЯЮТСЯ (сверка было->стало); net считается ВЫРАЖЕНИЕМ в SELECT"
  accrual_basis: return_date              # возврат относится к периоду по дате возврата
  fact_contract:
    wb: "1 fact row на return_id/model; quantity=1; amount=ABS(for_pay)"
    ozon: "latest whole row на return_id/offer_id; только ClientReturn; quantity из product.quantity; amount=unit_price*quantity"
    lamoda: "latest exact item_id; quantity=COUNT(item_id), legacy NULL-item fallback=1; amount=per_unit_revenue*quantity"
    yandex_market: "latest return header + authoritative items[]; quantity=SUM(items[].count); amount=per_unit_revenue*quantity"
  filters:
    - "return_date IS NULL -> НЕ вычитается (вне BETWEEN)"
    - "amount >= 0 -> отрицательные amount в вычет НЕ идут"
    - "quantity NULL -> COALESCE(...,1) только для legacy-строк; текущие ETL всегда пишут явное число единиц"
  applied_in:
    rating:   "profit на net (revenue-returns_amount) - cost*(qty_sold-returns_qty)"
    turnover: "делитель = net_sales = sales_28d - returns_qty"
  decision: DNR-005

# ─────────────────────────────────────────────────────────────────────────────
# 8. INVARIANTS для CI — тест грузит YAML и проверяет равенство с кодом.
#    Любой FAIL = баг (или YAML устарел, или код изменился без обновления YAML).
# ─────────────────────────────────────────────────────────────────────────────
invariants:
  constants_vs_code:
    - "constants.logistics_markup_usd == build_fact_rating.LOGISTICS_MARKUP_USD (3.0)"
    - "constants.customs_coefficient == build_fact_rating.CUSTOMS_COEFFICIENT (1.47)"
    - "constants.fallback_usd_rub_rate == build_fact_rating.FALLBACK_USD_RUB_RATE (96.0)"
    - "constants.turnover_window_days == 28 (build_fact_turnover окно и множитель)"
  seasonality_vs_code:
    - "seasonality.by_month (все 12) == build_fact_rating.SEASONALITY поэлементно"
    - "seasonality.default_if_missing == 1.0 (SEASONALITY.get(m, 1.0))"
  groups_vs_canon:
    - "rating.mp_spec[k].sales_group == canon.PRIMARY_GROUPS[k] для k in {1,3,6,9}"
    - "rating.mp_spec[k].status == canon.REALIZED_STATUS[первичный k]"
    - "turnover группировка {1:(1,2),3:(3,4,5),6:(6,8),9:(9,)} == canon.PRIMARY_GROUPS"
  formula_shape:
    - "cost_price.formula == ROUND((price_buy_usd+3)*1.47*rate, 2)"
    - "rating.formula.rating == ROUND(profit_raw/(exposure_days*cost_price_raw_rub*seasonality), 6)"
    - "turnover.formula.turnover_28d == ROUND(avg_stock_28d*28/net_sales, 2)"
  semantics:
    - "rating.status_set == turnover.status_set == 'realized' (DNR-001, epic-05/01)"
    - "rating.net_of_returns == turnover.net_of_returns == true (DNR-005)"
    - "demand НЕ используется ни в rating, ни в turnover (DNR-001)"
  example_cost:
    - "cost_price.example: (10+3)*1.47*96 == 1834.56"
