< Summary

Information
Class: Services.BitrixStockServices.PriceOptimizationManager
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/BitrixStockServices/PriceOptimizationManager.cs
Line coverage
100%
Covered lines: 59
Uncovered lines: 0
Coverable lines: 59
Total lines: 187
Line coverage: 100%
Branch coverage
87%
Covered branches: 51
Total branches: 58
Branch coverage: 87.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Calculate(...)84.78%4646100%
CalcBasePrice(...)100%66100%
ApplyDiscount(...)100%22100%
PickWinner(...)100%44100%

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>
 9    public decimal? C0 { get; init; }
 10
 11    /// <summary>C1 — цена из WarehouseStockSnapshot.C1ExistMinus5Percent.</summary>
 12    public decimal? C1 { get; init; }
 13
 14    /// <summary>JSON-объект одного элемента из Ozon /v5/product/info/prices (item-уровень).</summary>
 15    public JObject PriceJson { get; init; } = new();
 16
 17    /// <summary>Участвует ли товар в акции Ozon на момент построения отчёта.</summary>
 18    public bool InPromo { get; init; }
 19
 20    /// <summary>Надбавка p, применяемая когда товар НЕ в акции (%).</summary>
 21    public decimal NoPromoMarkupPercent { get; init; } = 5m;
 22
 23    /// <summary>Надбавка p, применяемая когда товар В акции (%).</summary>
 24    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    {
 3996        ArgumentNullException.ThrowIfNull(input);
 97
 3898        var commissions  = input.PriceJson["commissions"];
 3899        var priceIndexes = input.PriceJson["price_indexes"];
 100
 38101        decimal salesPctFbs    = commissions?["sales_percent_fbs"]?.Value<decimal>() ?? 0m;
 102        // acquiring — абсолютная сумма эквайринга в ₽ (не процент), поэтому пересчитываем в %
 38103        decimal acquiringAbs   = input.PriceJson["acquiring"]?.Value<decimal>() ?? 0m;
 38104        decimal currentPrice   = input.PriceJson["price"]?["marketing_seller_price"]?.Value<decimal>()
 38105                              ?? input.PriceJson["price"]?["price"]?.Value<decimal>()
 38106                              ?? 0m;
 38107        decimal acquiringPct   = currentPrice > 0m ? acquiringAbs / currentPrice * 100m : 0m;
 38108        decimal rate           = (salesPctFbs + acquiringPct) / 100m;
 109
 38110        if (rate <= 0m || rate >= 1m)
 111        {
 7112            return new PriceOptimizationResult
 7113            {
 7114                Error = $"Ставка комиссии {rate:P0} недействительна (ожидается 0 < rate < 1)."
 7115            };
 116        }
 117
 31118        decimal firstMileMax  = commissions?["fbs_first_mile_max_amount"]?.Value<decimal>() ?? 0m;
 31119        decimal directFlowMax = commissions?["fbs_direct_flow_trans_max_amount"]?.Value<decimal>() ?? 0m;
 31120        decimal lastMile      = commissions?["fbs_deliv_to_customer_amount"]?.Value<decimal>() ?? 0m;
 31121        decimal cFixedMax     = firstMileMax + directFlowMax + lastMile;
 122
 123        // Ozon отдаёт min_price = 0, когда данных по конкурентам нет (индекс WITHOUT_INDEX). Это не цена, а её
 124        // отсутствие: иначе «ближайший к нулю» кандидат — самая низкая цена по C₁ (Exist), и товар уходит
 125        // ниже себестоимости (60264727SX: 2123 → 682 при C₀ 925), а при следующем цикле, когда индекс
 126        // возвращается, цена прыгает обратно.
 31127        decimal? pMin = priceIndexes?["ozon_index_data"]?["min_price"]?.Value<decimal?>();
 31128        if (pMin is <= 0m)
 4129            pMin = null;
 130
 31131        decimal pPct = input.InPromo ? input.PromoMarkupPercent : input.NoPromoMarkupPercent;
 132
 31133        var p0  = CalcBasePrice(input.C0, cFixedMax, rate);
 31134        var p1  = CalcBasePrice(input.C1, cFixedMax, rate);
 31135        var p0d = ApplyDiscount(p0, pPct);
 31136        var p1d = ApplyDiscount(p1, pPct);
 137
 31138        var (winnerPrice, winnerSource) = PickWinner(p0d, p1d, pMin);
 139
 31140        return new PriceOptimizationResult
 31141        {
 31142            Rate              = rate,
 31143            SalesPctFbs       = salesPctFbs,
 31144            Acquiring         = acquiringPct,
 31145            CFixedMax         = cFixedMax,
 31146            FirstMileMax      = firstMileMax,
 31147            DirectFlowMax     = directFlowMax,
 31148            LastMile          = lastMile,
 31149            PMin              = pMin,
 31150            P0                = p0,
 31151            P1                = p1,
 31152            P0Discounted      = p0d,
 31153            P1Discounted      = p1d,
 31154            WinnerPrice       = winnerPrice,
 31155            WinnerSource      = winnerSource,
 31156            MarkupPercentUsed = pPct
 31157        };
 158    }
 159
 160    // ── Вспомогательные методы ───────────────────────────────────────────────
 161
 162    private static decimal? CalcBasePrice(decimal? c, decimal cFixed, decimal rate)
 163    {
 83164        if (c is null or <= 0m) return null;
 41165        return (c.Value + cFixed) / (1m - rate);
 166    }
 167
 168    private static decimal? ApplyDiscount(decimal? price, decimal pPct)
 62169        => price.HasValue ? price.Value * (1m - pPct / 100m) : null;
 170
 171    private static (decimal? price, string? source) PickWinner(decimal? p0d, decimal? p1d, decimal? pMin)
 172    {
 31173        var candidates = new (decimal? value, string label)[]
 31174        {
 31175            (p0d, "C₀"),
 31176            (p1d, "C₁"),
 31177        };
 178
 93179        var available = candidates.Where(c => c.value.HasValue).ToArray();
 32180        if (available.Length == 0) return (null, null);
 181
 40182        if (pMin is null) return (available[0].value, available[0].label);
 183
 46184        var best = available.MinBy(c => Math.Abs(c.value!.Value - pMin.Value));
 20185        return (best.value, best.label);
 186    }
 187}