| | | 1 | | using Newtonsoft.Json.Linq; |
| | | 2 | | |
| | | 3 | | namespace Services.BitrixStockServices; |
| | | 4 | | |
| | | 5 | | /// <summary>Входные данные для расчёта оптимальной цены по Модели 2.</summary> |
| | | 6 | | public 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> |
| | | 28 | | public 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> |
| | | 88 | | public static class PriceOptimizationManager |
| | | 89 | | { |
| | | 90 | | /// <summary> |
| | | 91 | | /// Вычисляет оптимальную цену. Не бросает исключений: при нехватке данных |
| | | 92 | | /// возвращает результат с заполненным <see cref="PriceOptimizationResult.Error"/>. |
| | | 93 | | /// </summary> |
| | | 94 | | public static PriceOptimizationResult Calculate(PriceOptimizationInput input) |
| | | 95 | | { |
| | 39 | 96 | | ArgumentNullException.ThrowIfNull(input); |
| | | 97 | | |
| | 38 | 98 | | var commissions = input.PriceJson["commissions"]; |
| | 38 | 99 | | var priceIndexes = input.PriceJson["price_indexes"]; |
| | | 100 | | |
| | 38 | 101 | | decimal salesPctFbs = commissions?["sales_percent_fbs"]?.Value<decimal>() ?? 0m; |
| | | 102 | | // acquiring — абсолютная сумма эквайринга в ₽ (не процент), поэтому пересчитываем в % |
| | 38 | 103 | | decimal acquiringAbs = input.PriceJson["acquiring"]?.Value<decimal>() ?? 0m; |
| | 38 | 104 | | decimal currentPrice = input.PriceJson["price"]?["marketing_seller_price"]?.Value<decimal>() |
| | 38 | 105 | | ?? input.PriceJson["price"]?["price"]?.Value<decimal>() |
| | 38 | 106 | | ?? 0m; |
| | 38 | 107 | | decimal acquiringPct = currentPrice > 0m ? acquiringAbs / currentPrice * 100m : 0m; |
| | 38 | 108 | | decimal rate = (salesPctFbs + acquiringPct) / 100m; |
| | | 109 | | |
| | 38 | 110 | | if (rate <= 0m || rate >= 1m) |
| | | 111 | | { |
| | 7 | 112 | | return new PriceOptimizationResult |
| | 7 | 113 | | { |
| | 7 | 114 | | Error = $"Ставка комиссии {rate:P0} недействительна (ожидается 0 < rate < 1)." |
| | 7 | 115 | | }; |
| | | 116 | | } |
| | | 117 | | |
| | 31 | 118 | | decimal firstMileMax = commissions?["fbs_first_mile_max_amount"]?.Value<decimal>() ?? 0m; |
| | 31 | 119 | | decimal directFlowMax = commissions?["fbs_direct_flow_trans_max_amount"]?.Value<decimal>() ?? 0m; |
| | 31 | 120 | | decimal lastMile = commissions?["fbs_deliv_to_customer_amount"]?.Value<decimal>() ?? 0m; |
| | 31 | 121 | | decimal cFixedMax = firstMileMax + directFlowMax + lastMile; |
| | | 122 | | |
| | | 123 | | // Ozon отдаёт min_price = 0, когда данных по конкурентам нет (индекс WITHOUT_INDEX). Это не цена, а её |
| | | 124 | | // отсутствие: иначе «ближайший к нулю» кандидат — самая низкая цена по C₁ (Exist), и товар уходит |
| | | 125 | | // ниже себестоимости (60264727SX: 2123 → 682 при C₀ 925), а при следующем цикле, когда индекс |
| | | 126 | | // возвращается, цена прыгает обратно. |
| | 31 | 127 | | decimal? pMin = priceIndexes?["ozon_index_data"]?["min_price"]?.Value<decimal?>(); |
| | 31 | 128 | | if (pMin is <= 0m) |
| | 4 | 129 | | pMin = null; |
| | | 130 | | |
| | 31 | 131 | | decimal pPct = input.InPromo ? input.PromoMarkupPercent : input.NoPromoMarkupPercent; |
| | | 132 | | |
| | 31 | 133 | | var p0 = CalcBasePrice(input.C0, cFixedMax, rate); |
| | 31 | 134 | | var p1 = CalcBasePrice(input.C1, cFixedMax, rate); |
| | 31 | 135 | | var p0d = ApplyDiscount(p0, pPct); |
| | 31 | 136 | | var p1d = ApplyDiscount(p1, pPct); |
| | | 137 | | |
| | 31 | 138 | | var (winnerPrice, winnerSource) = PickWinner(p0d, p1d, pMin); |
| | | 139 | | |
| | 31 | 140 | | return new PriceOptimizationResult |
| | 31 | 141 | | { |
| | 31 | 142 | | Rate = rate, |
| | 31 | 143 | | SalesPctFbs = salesPctFbs, |
| | 31 | 144 | | Acquiring = acquiringPct, |
| | 31 | 145 | | CFixedMax = cFixedMax, |
| | 31 | 146 | | FirstMileMax = firstMileMax, |
| | 31 | 147 | | DirectFlowMax = directFlowMax, |
| | 31 | 148 | | LastMile = lastMile, |
| | 31 | 149 | | PMin = pMin, |
| | 31 | 150 | | P0 = p0, |
| | 31 | 151 | | P1 = p1, |
| | 31 | 152 | | P0Discounted = p0d, |
| | 31 | 153 | | P1Discounted = p1d, |
| | 31 | 154 | | WinnerPrice = winnerPrice, |
| | 31 | 155 | | WinnerSource = winnerSource, |
| | 31 | 156 | | MarkupPercentUsed = pPct |
| | 31 | 157 | | }; |
| | | 158 | | } |
| | | 159 | | |
| | | 160 | | // ── Вспомогательные методы ─────────────────────────────────────────────── |
| | | 161 | | |
| | | 162 | | private static decimal? CalcBasePrice(decimal? c, decimal cFixed, decimal rate) |
| | | 163 | | { |
| | 83 | 164 | | if (c is null or <= 0m) return null; |
| | 41 | 165 | | return (c.Value + cFixed) / (1m - rate); |
| | | 166 | | } |
| | | 167 | | |
| | | 168 | | private static decimal? ApplyDiscount(decimal? price, decimal pPct) |
| | 62 | 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 | | { |
| | 31 | 173 | | var candidates = new (decimal? value, string label)[] |
| | 31 | 174 | | { |
| | 31 | 175 | | (p0d, "C₀"), |
| | 31 | 176 | | (p1d, "C₁"), |
| | 31 | 177 | | }; |
| | | 178 | | |
| | 93 | 179 | | var available = candidates.Where(c => c.value.HasValue).ToArray(); |
| | 32 | 180 | | if (available.Length == 0) return (null, null); |
| | | 181 | | |
| | 40 | 182 | | if (pMin is null) return (available[0].value, available[0].label); |
| | | 183 | | |
| | 46 | 184 | | var best = available.MinBy(c => Math.Abs(c.value!.Value - pMin.Value)); |
| | 20 | 185 | | return (best.value, best.label); |
| | | 186 | | } |
| | | 187 | | } |