< Summary

Information
Class: Services.BitrixStockServices.PriceOptimizationInput
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/PriceOptimizationManager.cs
Line coverage
100%
Covered lines: 6
Uncovered lines: 0
Coverable lines: 6
Total lines: 187
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_C0()100%11100%
get_C1()100%11100%
get_PriceJson()100%11100%
get_InPromo()100%11100%
get_NoPromoMarkupPercent()100%11100%
get_PromoMarkupPercent()100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/PriceOptimizationManager.cs

#LineLine coverage
 1using Newtonsoft.Json.Linq;
 2
 3namespace Services.BitrixStockServices;
 4
 5/// <summary>Входные данные для расчёта оптимальной цены по Модели 2.</summary>
 6public sealed class PriceOptimizationInput
 7{
 8    /// <summary>C0 — цена 1С из строки отчёта (BitrixStockSyncReportLines.OneCPrice).</summary>
 699    public decimal? C0 { get; init; }
 10
 11    /// <summary>C1 — цена из WarehouseStockSnapshot.C1ExistMinus5Percent.</summary>
 5312    public decimal? C1 { get; init; }
 13
 14    /// <summary>JSON-объект одного элемента из Ozon /v5/product/info/prices (item-уровень).</summary>
 23215    public JObject PriceJson { get; init; } = new();
 16
 17    /// <summary>Участвует ли товар в акции Ozon на момент построения отчёта.</summary>
 3818    public bool InPromo { get; init; }
 19
 20    /// <summary>Надбавка p, применяемая когда товар НЕ в акции (%).</summary>
 6821    public decimal NoPromoMarkupPercent { get; init; } = 5m;
 22
 23    /// <summary>Надбавка p, применяемая когда товар В акции (%).</summary>
 4924    public decimal PromoMarkupPercent { get; init; } = 10m;
 25}
 26
 27/// <summary>Результат расчёта оптимальной цены.</summary>
 28public sealed class PriceOptimizationResult
 29{
 30    /// <summary>Суммарная ставка: (sales_percent_fbs + acquiring%) / 100.</summary>
 31    public decimal Rate { get; init; }
 32
 33    // Детальные составляющие ставки (для отображения формулы)
 34    public decimal SalesPctFbs  { get; init; }
 35    /// <summary>Эквайринг в % (= acquiring_abs / marketing_seller_price * 100).</summary>
 36    public decimal Acquiring    { get; init; }
 37
 38    /// <summary>Максимальные фиксированные сборы FBS: first_mile_max + direct_flow_max + last_mile.</summary>
 39    public decimal CFixedMax { get; init; }
 40
 41    // Детальные составляющие C_fixed (для отображения формулы)
 42    public decimal FirstMileMax  { get; init; }
 43    public decimal DirectFlowMax { get; init; }
 44    public decimal LastMile      { get; init; }
 45
 46    /// <summary>Минимальная цена конкурентов на Ozon (price_indexes.ozon_index_data.min_price). Null если нет данных (в
 47    public decimal? PMin { get; init; }
 48
 49    /// <summary>Базовая цена по C0: (C0 + CFixedMax) / (1 - Rate). Null если C0 недоступна.</summary>
 50    public decimal? P0 { get; init; }
 51
 52    /// <summary>Базовая цена по C1: (C1 + CFixedMax) / (1 - Rate). Null если C1 недоступна.</summary>
 53    public decimal? P1 { get; init; }
 54
 55    /// <summary>P0 × (1 − p/100).</summary>
 56    public decimal? P0Discounted { get; init; }
 57
 58    /// <summary>P1 × (1 − p/100).</summary>
 59    public decimal? P1Discounted { get; init; }
 60
 61    /// <summary>Рекомендуемая цена: кандидат, ближайший к PMin по модулю. Null если нет данных.</summary>
 62    public decimal? WinnerPrice { get; init; }
 63
 64    /// <summary>"C₀" или "C₁" — источник победившей цены.</summary>
 65    public string? WinnerSource { get; init; }
 66
 67    /// <summary>Фактически применённая надбавка p (%).</summary>
 68    public decimal MarkupPercentUsed { get; init; }
 69
 70    /// <summary>Описание ошибки — не null, если расчёт невозможен (нет комиссий и т.п.).</summary>
 71    public string? Error { get; init; }
 72
 73    public bool HasData => Error is null;
 74}
 75
 76/// <summary>
 77/// Расчёт оптимальной цены по Модели 2 (формула с учётом акций Ozon).
 78///
 79/// Формула:
 80///   rate         = (sales_percent_fbs + acquiring) / 100
 81///   C_fixed_max  = fbs_first_mile_max + fbs_direct_flow_trans_max + fbs_deliv_to_customer_amount
 82///   P[i]         = (C[i] + C_fixed_max) / (1 − rate)
 83///   P[i, disc]   = P[i] × (1 − p/100)
 84///   Winner       = argmin |P[i, disc] − P_min|  среди ненулевых кандидатов
 85///
 86/// Источник полей: Ozon /v5/product/info/prices → item.commissions / item.price_indexes.
 87/// </summary>
 88public static class PriceOptimizationManager
 89{
 90    /// <summary>
 91    /// Вычисляет оптимальную цену. Не бросает исключений: при нехватке данных
 92    /// возвращает результат с заполненным <see cref="PriceOptimizationResult.Error"/>.
 93    /// </summary>
 94    public static PriceOptimizationResult Calculate(PriceOptimizationInput input)
 95    {
 96        ArgumentNullException.ThrowIfNull(input);
 97
 98        var commissions  = input.PriceJson["commissions"];
 99        var priceIndexes = input.PriceJson["price_indexes"];
 100
 101        decimal salesPctFbs    = commissions?["sales_percent_fbs"]?.Value<decimal>() ?? 0m;
 102        // acquiring — абсолютная сумма эквайринга в ₽ (не процент), поэтому пересчитываем в %
 103        decimal acquiringAbs   = input.PriceJson["acquiring"]?.Value<decimal>() ?? 0m;
 104        decimal currentPrice   = input.PriceJson["price"]?["marketing_seller_price"]?.Value<decimal>()
 105                              ?? input.PriceJson["price"]?["price"]?.Value<decimal>()
 106                              ?? 0m;
 107        decimal acquiringPct   = currentPrice > 0m ? acquiringAbs / currentPrice * 100m : 0m;
 108        decimal rate           = (salesPctFbs + acquiringPct) / 100m;
 109
 110        if (rate <= 0m || rate >= 1m)
 111        {
 112            return new PriceOptimizationResult
 113            {
 114                Error = $"Ставка комиссии {rate:P0} недействительна (ожидается 0 < rate < 1)."
 115            };
 116        }
 117
 118        decimal firstMileMax  = commissions?["fbs_first_mile_max_amount"]?.Value<decimal>() ?? 0m;
 119        decimal directFlowMax = commissions?["fbs_direct_flow_trans_max_amount"]?.Value<decimal>() ?? 0m;
 120        decimal lastMile      = commissions?["fbs_deliv_to_customer_amount"]?.Value<decimal>() ?? 0m;
 121        decimal cFixedMax     = firstMileMax + directFlowMax + lastMile;
 122
 123        // Ozon отдаёт min_price = 0, когда данных по конкурентам нет (индекс WITHOUT_INDEX). Это не цена, а её
 124        // отсутствие: иначе «ближайший к нулю» кандидат — самая низкая цена по C₁ (Exist), и товар уходит
 125        // ниже себестоимости (60264727SX: 2123 → 682 при C₀ 925), а при следующем цикле, когда индекс
 126        // возвращается, цена прыгает обратно.
 127        decimal? pMin = priceIndexes?["ozon_index_data"]?["min_price"]?.Value<decimal?>();
 128        if (pMin is <= 0m)
 129            pMin = null;
 130
 131        decimal pPct = input.InPromo ? input.PromoMarkupPercent : input.NoPromoMarkupPercent;
 132
 133        var p0  = CalcBasePrice(input.C0, cFixedMax, rate);
 134        var p1  = CalcBasePrice(input.C1, cFixedMax, rate);
 135        var p0d = ApplyDiscount(p0, pPct);
 136        var p1d = ApplyDiscount(p1, pPct);
 137
 138        var (winnerPrice, winnerSource) = PickWinner(p0d, p1d, pMin);
 139
 140        return new PriceOptimizationResult
 141        {
 142            Rate              = rate,
 143            SalesPctFbs       = salesPctFbs,
 144            Acquiring         = acquiringPct,
 145            CFixedMax         = cFixedMax,
 146            FirstMileMax      = firstMileMax,
 147            DirectFlowMax     = directFlowMax,
 148            LastMile          = lastMile,
 149            PMin              = pMin,
 150            P0                = p0,
 151            P1                = p1,
 152            P0Discounted      = p0d,
 153            P1Discounted      = p1d,
 154            WinnerPrice       = winnerPrice,
 155            WinnerSource      = winnerSource,
 156            MarkupPercentUsed = pPct
 157        };
 158    }
 159
 160    // ── Вспомогательные методы ───────────────────────────────────────────────
 161
 162    private static decimal? CalcBasePrice(decimal? c, decimal cFixed, decimal rate)
 163    {
 164        if (c is null or <= 0m) return null;
 165        return (c.Value + cFixed) / (1m - rate);
 166    }
 167
 168    private static decimal? ApplyDiscount(decimal? price, decimal pPct)
 169        => price.HasValue ? price.Value * (1m - pPct / 100m) : null;
 170
 171    private static (decimal? price, string? source) PickWinner(decimal? p0d, decimal? p1d, decimal? pMin)
 172    {
 173        var candidates = new (decimal? value, string label)[]
 174        {
 175            (p0d, "C₀"),
 176            (p1d, "C₁"),
 177        };
 178
 179        var available = candidates.Where(c => c.value.HasValue).ToArray();
 180        if (available.Length == 0) return (null, null);
 181
 182        if (pMin is null) return (available[0].value, available[0].label);
 183
 184        var best = available.MinBy(c => Math.Abs(c.value!.Value - pMin.Value));
 185        return (best.value, best.label);
 186    }
 187}