| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Text; |
| | | 3 | | using Microsoft.Extensions.Http; |
| | | 4 | | using OzonDomains.Models; |
| | | 5 | | using Services.ApiServices.OzonApi; |
| | | 6 | | using Services.ApiServices.OzonApi.Filters; |
| | | 7 | | using SlqStudio.Application.Services.EmailService; |
| | | 8 | | |
| | | 9 | | namespace Services.ImportProductPricesServices; |
| | | 10 | | |
| | | 11 | | public class ImportProductPricesManager |
| | | 12 | | { |
| | | 13 | | private readonly IHttpClientFactory _httpClientFactory; |
| | | 14 | | private readonly IEmailService? _emailService; |
| | | 15 | | private OzonApiDataManager? _ozonApiDataManager; |
| | | 16 | | |
| | | 17 | | public ImportProductPricesManager(IHttpClientFactory httpClientFactory, IEmailService? emailService = null) |
| | | 18 | | { |
| | | 19 | | _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); |
| | | 20 | | _emailService = emailService; |
| | | 21 | | } |
| | | 22 | | |
| | | 23 | | public async Task<ProductImportPricesResponse> SetProductPrices(string article, |
| | | 24 | | OzonClient ozonClient, |
| | | 25 | | double YourPrice, double OldPrice, |
| | | 26 | | double MinPrice, double CostPrice) |
| | | 27 | | { |
| | | 28 | | var pricesForArticle = await GetProductPrices(article, ozonClient); |
| | | 29 | | if (pricesForArticle?.Items == null || pricesForArticle.Items.Count == 0) |
| | | 30 | | { |
| | | 31 | | return new ProductImportPricesResponse |
| | | 32 | | { |
| | | 33 | | Result = new List<ProductImportPriceResult> |
| | | 34 | | { |
| | | 35 | | new ProductImportPriceResult |
| | | 36 | | { |
| | | 37 | | OfferId = article, |
| | | 38 | | ProductId = 0, |
| | | 39 | | Updated = false, |
| | | 40 | | Errors = [new ProductImportPriceError() |
| | | 41 | | { |
| | | 42 | | Message = "Цены не найдены", |
| | | 43 | | Code = "SERVER_ERROR" |
| | | 44 | | }] |
| | | 45 | | } |
| | | 46 | | } |
| | | 47 | | }; |
| | | 48 | | } |
| | | 49 | | try |
| | | 50 | | { |
| | | 51 | | var result = await SetUserPrice(pricesForArticle.Items[0], ozonClient, |
| | | 52 | | YourPrice, OldPrice, MinPrice, CostPrice); |
| | | 53 | | return result; |
| | | 54 | | } |
| | | 55 | | catch (Exception ex) |
| | | 56 | | { |
| | | 57 | | return new ProductImportPricesResponse |
| | | 58 | | { |
| | | 59 | | Result = new List<ProductImportPriceResult> |
| | | 60 | | { |
| | | 61 | | new ProductImportPriceResult |
| | | 62 | | { |
| | | 63 | | OfferId = article, |
| | | 64 | | ProductId = 0, |
| | | 65 | | Updated = false, |
| | | 66 | | Errors = [new ProductImportPriceError() |
| | | 67 | | { |
| | | 68 | | Message = $"Ошибка при расчёте цены: {ex.Message}", |
| | | 69 | | Code = "SERVER_ERROR" |
| | | 70 | | }] |
| | | 71 | | } |
| | | 72 | | } |
| | | 73 | | }; |
| | | 74 | | } |
| | | 75 | | } |
| | | 76 | | |
| | | 77 | | /// <summary> |
| | | 78 | | /// Устанавливает цену напрямую по известным productId/offerId/vat из снимка, без запроса к Ozon. |
| | | 79 | | /// </summary> |
| | | 80 | | public async Task<ProductImportPricesResponse> SetProductPricesDirect( |
| | | 81 | | string offerId, long productId, string vat, |
| | | 82 | | OzonClient ozonClient, |
| | | 83 | | double yourPrice, double oldPrice, double minPrice, double costPrice) |
| | | 84 | | { |
| | | 85 | | EnsureClient(ozonClient); |
| | | 86 | | var request = new List<ProductPriceItem> |
| | | 87 | | { |
| | | 88 | | new ProductPriceItem |
| | | 89 | | { |
| | | 90 | | OfferId = offerId, |
| | | 91 | | ProductId = productId, |
| | | 92 | | NetPrice = Math.Round(costPrice, MidpointRounding.AwayFromZero).ToString(CultureInfo.InvariantCulture) |
| | | 93 | | MinPrice = Math.Round(minPrice, MidpointRounding.AwayFromZero).ToString(CultureInfo.InvariantCulture) |
| | | 94 | | Price = Math.Round(yourPrice, MidpointRounding.AwayFromZero).ToString(CultureInfo.InvariantCulture) |
| | | 95 | | OldPrice = Math.Round(oldPrice, MidpointRounding.AwayFromZero).ToString(CultureInfo.InvariantCulture) |
| | | 96 | | Vat = vat, |
| | | 97 | | } |
| | | 98 | | }; |
| | | 99 | | return await _ozonApiDataManager!.ImportProductPrices(request); |
| | | 100 | | } |
| | | 101 | | |
| | | 102 | | public async Task<ProductImportPricesResponse> UpdateProductPrices(string article, |
| | | 103 | | OzonClient ozonClient, |
| | | 104 | | double constantIndex, |
| | | 105 | | double percent) |
| | | 106 | | { |
| | | 107 | | var pricesForArticle = await GetProductPrices(article, ozonClient); |
| | | 108 | | if (pricesForArticle?.Items == null || pricesForArticle.Items.Count == 0) |
| | | 109 | | { |
| | | 110 | | return new ProductImportPricesResponse |
| | | 111 | | { |
| | | 112 | | Result = new List<ProductImportPriceResult> |
| | | 113 | | { |
| | | 114 | | new ProductImportPriceResult |
| | | 115 | | { |
| | | 116 | | OfferId = article, |
| | | 117 | | ProductId = 0, |
| | | 118 | | Updated = false, |
| | | 119 | | Errors = [new ProductImportPriceError() |
| | | 120 | | { |
| | | 121 | | Message = "Цены не найдены", |
| | | 122 | | Code = "SERVER_ERROR" |
| | | 123 | | }] |
| | | 124 | | } |
| | | 125 | | } |
| | | 126 | | }; |
| | | 127 | | } |
| | | 128 | | try |
| | | 129 | | { |
| | | 130 | | var recommendation = await GetPriceDumpingPreview(pricesForArticle.Items[0], ozonClient, constantIndex, perc |
| | | 131 | | if (recommendation == null) |
| | | 132 | | throw new InvalidOperationException("Не удалось рассчитать рекомендованную цену для демпинга"); |
| | | 133 | | |
| | | 134 | | var result = await SetNewPrice(pricesForArticle.Items[0], recommendation.TargetSellerPrice, ozonClient); |
| | | 135 | | return result; |
| | | 136 | | } |
| | | 137 | | catch (Exception ex) |
| | | 138 | | { |
| | | 139 | | return new ProductImportPricesResponse |
| | | 140 | | { |
| | | 141 | | Result = new List<ProductImportPriceResult> |
| | | 142 | | { |
| | | 143 | | new ProductImportPriceResult |
| | | 144 | | { |
| | | 145 | | OfferId = article, |
| | | 146 | | ProductId = 0, |
| | | 147 | | Updated = false, |
| | | 148 | | Errors = [new ProductImportPriceError() |
| | | 149 | | { |
| | | 150 | | Message = $"Ошибка при расчёте цены: {ex.Message}", |
| | | 151 | | Code = "SERVER_ERROR" |
| | | 152 | | }] |
| | | 153 | | } |
| | | 154 | | } |
| | | 155 | | }; |
| | | 156 | | } |
| | | 157 | | } |
| | | 158 | | |
| | | 159 | | private async Task<ProductPricesResponse> GetProductPrices(string article, OzonClient ozonClient) |
| | | 160 | | { |
| | | 161 | | EnsureClient(ozonClient); |
| | | 162 | | |
| | | 163 | | return await _ozonApiDataManager!.GetProductPricesByArticlesModel(new[] { article }); |
| | | 164 | | } |
| | | 165 | | |
| | | 166 | | public async Task<PriceDumpingPreview?> GetPriceDumpingPreview( |
| | | 167 | | ProductPriceItemResponse productPriceItemResponse, |
| | | 168 | | OzonClient ozonClient, |
| | | 169 | | double constantIndex = 0.91, |
| | | 170 | | double percent = 2) |
| | | 171 | | { |
| | | 172 | | if (productPriceItemResponse == null) |
| | | 173 | | throw new ArgumentNullException(nameof(productPriceItemResponse)); |
| | | 174 | | |
| | | 175 | | EnsureClient(ozonClient); |
| | | 176 | | var priceDetails = await GetProductPriceDetails(productPriceItemResponse); |
| | | 177 | | return BuildPriceDumpingPreview(productPriceItemResponse, priceDetails, constantIndex, percent); |
| | | 178 | | } |
| | | 179 | | |
| | | 180 | | internal PriceDumpingPreview BuildPriceDumpingPreview( |
| | | 181 | | ProductPriceItemResponse productPriceItemResponse, |
| | | 182 | | ProductPriceDetailsItemResponse? priceDetails, |
| | | 183 | | double constantIndex = 0.91, |
| | | 184 | | double percent = 2) |
| | | 185 | | { |
| | | 186 | | if (productPriceItemResponse == null) |
| | | 187 | | throw new ArgumentNullException(nameof(productPriceItemResponse), "Объект цены не может быть null"); |
| | | 188 | | |
| | | 189 | | if (productPriceItemResponse.Price == null) |
| | | 190 | | throw new ArgumentException("Данные о цене отсутствуют", nameof(productPriceItemResponse)); |
| | | 191 | | |
| | | 192 | | if (productPriceItemResponse.PriceIndexes == null || |
| | | 193 | | productPriceItemResponse.PriceIndexes.OzonIndexData == null) |
| | | 194 | | throw new ArgumentException("Отсутствуют данные об индексах цен", nameof(productPriceItemResponse)); |
| | | 195 | | |
| | | 196 | | if (constantIndex <= 0) |
| | | 197 | | throw new ArgumentOutOfRangeException(nameof(constantIndex), "Коэффициент должен быть больше 0"); |
| | | 198 | | |
| | | 199 | | if (percent <= 0) |
| | | 200 | | throw new ArgumentOutOfRangeException(nameof(percent), "Процент должен быть больше 0"); |
| | | 201 | | |
| | | 202 | | var sellerPrice = GetBasePrice(productPriceItemResponse); |
| | | 203 | | if (sellerPrice <= 0) |
| | | 204 | | throw new InvalidOperationException("У товара отсутствует маркетинговая или текущая цена для расчета"); |
| | | 205 | | |
| | | 206 | | const double fallbackCustomerFactor = 0.91d; |
| | | 207 | | |
| | | 208 | | var currentCustomerPriceSource = GetCurrentCustomerPriceSource(productPriceItemResponse, priceDetails, fallbackC |
| | | 209 | | var currentCustomerPrice = currentCustomerPriceSource.Price; |
| | | 210 | | if (currentCustomerPrice <= 0) |
| | | 211 | | throw new InvalidOperationException("Не удалось определить текущую цену покупателя для расчета демпинга"); |
| | | 212 | | |
| | | 213 | | var customerPriceFactor = currentCustomerPrice / sellerPrice; |
| | | 214 | | if (customerPriceFactor <= 0) |
| | | 215 | | customerPriceFactor = Math.Round(fallbackCustomerFactor, 2); |
| | | 216 | | |
| | | 217 | | customerPriceFactor = Math.Clamp(customerPriceFactor, 0.01d, 1d); |
| | | 218 | | |
| | | 219 | | var requestedTargetIndex = Math.Clamp(constantIndex, 0.01d, 2d); |
| | | 220 | | var targetIndex = GetTargetIndex(requestedTargetIndex, percent); |
| | | 221 | | var candidates = GetTargetCandidates(productPriceItemResponse, currentCustomerPrice, targetIndex, fallbackCustom |
| | | 222 | | .Where(x => x.TargetCustomerPrice > 0) |
| | | 223 | | .Select(x => new PriceDumpingCandidatePreview( |
| | | 224 | | x.Source, |
| | | 225 | | x.IndexValue, |
| | | 226 | | x.SourcePrice, |
| | | 227 | | x.TargetCustomerPrice, |
| | | 228 | | x.TargetCustomerPrice / customerPriceFactor)) |
| | | 229 | | .ToList(); |
| | | 230 | | |
| | | 231 | | if (candidates.Count == 0) |
| | | 232 | | throw new InvalidOperationException("Нет индексов цен Ozon, по которым можно рассчитать зеленую цену"); |
| | | 233 | | |
| | | 234 | | var bestCandidate = candidates |
| | | 235 | | .OrderByDescending(x => x.TargetCustomerPrice) |
| | | 236 | | .First(); |
| | | 237 | | |
| | | 238 | | var targetCustomerPrice = bestCandidate.TargetCustomerPrice; |
| | | 239 | | var targetSellerPrice = targetCustomerPrice / customerPriceFactor; |
| | | 240 | | |
| | | 241 | | if (targetSellerPrice <= 0) |
| | | 242 | | throw new InvalidOperationException("Рассчитанная цена продавца получилась меньше или равна 0"); |
| | | 243 | | |
| | | 244 | | var currentPriceExplanation = BuildCurrentPriceExplanation( |
| | | 245 | | currentCustomerPriceSource, |
| | | 246 | | sellerPrice, |
| | | 247 | | fallbackCustomerFactor); |
| | | 248 | | |
| | | 249 | | var candidatesExplanation = BuildCandidatesExplanation(candidates); |
| | | 250 | | var targetPriceExplanation = BuildTargetPriceExplanation( |
| | | 251 | | requestedTargetIndex, |
| | | 252 | | targetIndex, |
| | | 253 | | currentCustomerPrice, |
| | | 254 | | customerPriceFactor, |
| | | 255 | | percent, |
| | | 256 | | bestCandidate); |
| | | 257 | | |
| | | 258 | | return new PriceDumpingPreview |
| | | 259 | | { |
| | | 260 | | CurrentSellerPrice = Math.Round(sellerPrice, 2), |
| | | 261 | | CurrentCustomerPrice = Math.Round(currentCustomerPrice, 2), |
| | | 262 | | CustomerPriceFactor = Math.Round(customerPriceFactor, 4), |
| | | 263 | | TargetCustomerPrice = Math.Round(targetCustomerPrice, 2), |
| | | 264 | | TargetSellerPrice = Math.Round(targetSellerPrice, 2), |
| | | 265 | | TargetIndex = Math.Round(targetIndex, 2), |
| | | 266 | | Source = bestCandidate.Source, |
| | | 267 | | SourceIndexValue = Math.Round(bestCandidate.IndexValue, 2), |
| | | 268 | | SourceMinPrice = Math.Round(bestCandidate.SourcePrice, 2), |
| | | 269 | | DiscountPercent = Math.Round(priceDetails?.DiscountPercent * 100m ?? 0m, 2), |
| | | 270 | | CurrentPriceExplanation = currentPriceExplanation, |
| | | 271 | | TargetPriceExplanation = targetPriceExplanation, |
| | | 272 | | CandidatesExplanation = candidatesExplanation |
| | | 273 | | }; |
| | | 274 | | } |
| | | 275 | | |
| | | 276 | | private static double GetBasePrice(ProductPriceItemResponse productPriceItemResponse) |
| | | 277 | | { |
| | | 278 | | var price = productPriceItemResponse.Price; |
| | | 279 | | if (price == null) |
| | | 280 | | return 0; |
| | | 281 | | |
| | | 282 | | if (price.MarketingSellerPrice > 0) |
| | | 283 | | return price.MarketingSellerPrice; |
| | | 284 | | |
| | | 285 | | if (price.MarketingPrice > 0) |
| | | 286 | | return price.MarketingPrice; |
| | | 287 | | |
| | | 288 | | return price.Price > 0 ? price.Price : 0; |
| | | 289 | | } |
| | | 290 | | |
| | | 291 | | private static double GetMinIndexPrice(ProductPriceItemResponse productPriceItemResponse) |
| | | 292 | | { |
| | | 293 | | var priceIndexes = productPriceItemResponse.PriceIndexes; |
| | | 294 | | if (priceIndexes == null) |
| | | 295 | | return 0; |
| | | 296 | | |
| | | 297 | | if (priceIndexes.OzonIndexData?.MinPrice > 0) |
| | | 298 | | return priceIndexes.OzonIndexData.MinPrice; |
| | | 299 | | |
| | | 300 | | if (priceIndexes.ExternalIndexData?.MinPrice > 0) |
| | | 301 | | return priceIndexes.ExternalIndexData.MinPrice; |
| | | 302 | | |
| | | 303 | | if (priceIndexes.SelfMarketplacesIndexData?.MinPrice > 0) |
| | | 304 | | return priceIndexes.SelfMarketplacesIndexData.MinPrice; |
| | | 305 | | |
| | | 306 | | return productPriceItemResponse.Price?.MinPrice > 0 |
| | | 307 | | ? productPriceItemResponse.Price.MinPrice |
| | | 308 | | : 0; |
| | | 309 | | } |
| | | 310 | | |
| | | 311 | | private IEnumerable<PriceDumpingCandidate> GetTargetCandidates( |
| | | 312 | | ProductPriceItemResponse productPriceItemResponse, |
| | | 313 | | double currentCustomerPrice, |
| | | 314 | | double targetIndex, |
| | | 315 | | double constantIndex) |
| | | 316 | | { |
| | | 317 | | var priceIndexes = productPriceItemResponse.PriceIndexes; |
| | | 318 | | if (priceIndexes == null) |
| | | 319 | | yield break; |
| | | 320 | | |
| | | 321 | | var hasRealCandidates = false; |
| | | 322 | | |
| | | 323 | | if (priceIndexes.OzonIndexData?.PriceIndexValue > 0) |
| | | 324 | | { |
| | | 325 | | hasRealCandidates = true; |
| | | 326 | | yield return new PriceDumpingCandidate( |
| | | 327 | | "Ozon", |
| | | 328 | | priceIndexes.OzonIndexData.PriceIndexValue, |
| | | 329 | | priceIndexes.OzonIndexData.MinPrice, |
| | | 330 | | currentCustomerPrice * targetIndex / priceIndexes.OzonIndexData.PriceIndexValue); |
| | | 331 | | } |
| | | 332 | | |
| | | 333 | | if (priceIndexes.ExternalIndexData?.PriceIndexValue > 0) |
| | | 334 | | { |
| | | 335 | | hasRealCandidates = true; |
| | | 336 | | yield return new PriceDumpingCandidate( |
| | | 337 | | "Другие площадки", |
| | | 338 | | priceIndexes.ExternalIndexData.PriceIndexValue, |
| | | 339 | | priceIndexes.ExternalIndexData.MinPrice, |
| | | 340 | | currentCustomerPrice * targetIndex / priceIndexes.ExternalIndexData.PriceIndexValue); |
| | | 341 | | } |
| | | 342 | | |
| | | 343 | | if (priceIndexes.SelfMarketplacesIndexData?.PriceIndexValue > 0) |
| | | 344 | | { |
| | | 345 | | hasRealCandidates = true; |
| | | 346 | | yield return new PriceDumpingCandidate( |
| | | 347 | | "Ваши площадки", |
| | | 348 | | priceIndexes.SelfMarketplacesIndexData.PriceIndexValue, |
| | | 349 | | priceIndexes.SelfMarketplacesIndexData.MinPrice, |
| | | 350 | | currentCustomerPrice * targetIndex / priceIndexes.SelfMarketplacesIndexData.PriceIndexValue); |
| | | 351 | | } |
| | | 352 | | |
| | | 353 | | if (!hasRealCandidates) |
| | | 354 | | { |
| | | 355 | | var fallbackMinPrice = GetMinIndexPrice(productPriceItemResponse); |
| | | 356 | | var fallbackBasePrice = GetBasePrice(productPriceItemResponse); |
| | | 357 | | if (fallbackMinPrice > 0 && fallbackBasePrice > 0) |
| | | 358 | | { |
| | | 359 | | var fallbackTruePrice = GetTruePrice(fallbackBasePrice, constantIndex); |
| | | 360 | | var fallbackIndex = fallbackTruePrice > 0 ? fallbackTruePrice / fallbackMinPrice : 0; |
| | | 361 | | if (fallbackIndex > 0) |
| | | 362 | | { |
| | | 363 | | yield return new PriceDumpingCandidate( |
| | | 364 | | "Расчетный fallback", |
| | | 365 | | fallbackIndex, |
| | | 366 | | fallbackMinPrice, |
| | | 367 | | currentCustomerPrice * targetIndex / fallbackIndex); |
| | | 368 | | } |
| | | 369 | | } |
| | | 370 | | } |
| | | 371 | | } |
| | | 372 | | |
| | | 373 | | private static CurrentCustomerPriceSource GetCurrentCustomerPriceSource( |
| | | 374 | | ProductPriceItemResponse productPriceItemResponse, |
| | | 375 | | ProductPriceDetailsItemResponse? priceDetails, |
| | | 376 | | double constantIndex, |
| | | 377 | | double sellerPrice) |
| | | 378 | | { |
| | | 379 | | if (priceDetails?.CustomerPrice?.Amount > 0) |
| | | 380 | | return new CurrentCustomerPriceSource( |
| | | 381 | | (double)priceDetails.CustomerPrice.Amount, |
| | | 382 | | "prices/details.customer_price", |
| | | 383 | | $"Цена с картой взята из prices/details.customer_price = {priceDetails.CustomerPrice.Amount:0.##}"); |
| | | 384 | | |
| | | 385 | | if (productPriceItemResponse.Price?.Price > 0) |
| | | 386 | | return new CurrentCustomerPriceSource( |
| | | 387 | | productPriceItemResponse.Price.Price, |
| | | 388 | | "v5.price.price", |
| | | 389 | | $"Цена с картой взята из v5.price.price = {productPriceItemResponse.Price.Price:0.##}"); |
| | | 390 | | |
| | | 391 | | var fallbackPrice = GetTruePriceStatic(sellerPrice, constantIndex); |
| | | 392 | | return new CurrentCustomerPriceSource( |
| | | 393 | | fallbackPrice, |
| | | 394 | | "fallback", |
| | | 395 | | $"Цена с картой рассчитана fallback: {sellerPrice:0.##} * {Math.Round(constantIndex, 2):0.##} = {fallbackPri |
| | | 396 | | } |
| | | 397 | | |
| | | 398 | | private static string BuildCurrentPriceExplanation(CurrentCustomerPriceSource currentCustomerPriceSource, double sel |
| | | 399 | | { |
| | | 400 | | return $"Базовая цена продавца = {sellerPrice:0.##}\n" + |
| | | 401 | | $"Источник цены с картой: {currentCustomerPriceSource.Source}\n" + |
| | | 402 | | currentCustomerPriceSource.Explanation + |
| | | 403 | | (currentCustomerPriceSource.Source == "fallback" |
| | | 404 | | ? string.Empty |
| | | 405 | | : $"\nFallback для проверки: {sellerPrice:0.##} * {Math.Round(constantIndex, 2):0.##} = {GetTruePrice |
| | | 406 | | } |
| | | 407 | | |
| | | 408 | | private static string BuildCandidatesExplanation(IReadOnlyCollection<PriceDumpingCandidatePreview> candidates) |
| | | 409 | | { |
| | | 410 | | var builder = new StringBuilder(); |
| | | 411 | | foreach (var candidate in candidates.OrderBy(x => x.TargetCustomerPrice)) |
| | | 412 | | { |
| | | 413 | | if (builder.Length > 0) |
| | | 414 | | builder.AppendLine(); |
| | | 415 | | |
| | | 416 | | builder.Append($"{candidate.Source}: ориентир {candidate.SourcePrice:0.##}, индекс {candidate.IndexValue:0.# |
| | | 417 | | builder.Append($"цена с картой {candidate.TargetCustomerPrice:0.##}, цена продавца {candidate.TargetSellerPr |
| | | 418 | | } |
| | | 419 | | |
| | | 420 | | return builder.ToString(); |
| | | 421 | | } |
| | | 422 | | |
| | | 423 | | private static string BuildTargetPriceExplanation( |
| | | 424 | | double requestedTargetIndex, |
| | | 425 | | double targetIndex, |
| | | 426 | | double currentCustomerPrice, |
| | | 427 | | double customerPriceFactor, |
| | | 428 | | double percent, |
| | | 429 | | PriceDumpingCandidatePreview bestCandidate) |
| | | 430 | | { |
| | | 431 | | var sellerPrice = currentCustomerPrice / customerPriceFactor; |
| | | 432 | | |
| | | 433 | | return $"Запрошенный индекс = {requestedTargetIndex:0.##}\n" + |
| | | 434 | | $"Нормализованный целевой индекс = clamp({requestedTargetIndex:0.##}, 0.80, 0.90) - {percent:0.##}/1000 = |
| | | 435 | | $"Коэффициент цены с картой = {currentCustomerPrice:0.##} / {sellerPrice:0.##} = {customerPriceFactor:0.# |
| | | 436 | | $"Выбран рынок: {bestCandidate.Source}\n" + |
| | | 437 | | $"Реком. цена с картой = {currentCustomerPrice:0.##} * {targetIndex:0.##} / {bestCandidate.IndexValue:0.# |
| | | 438 | | $"Реком. цена продавца = {bestCandidate.TargetCustomerPrice:0.##} / {customerPriceFactor:0.####} = {bestC |
| | | 439 | | } |
| | | 440 | | |
| | | 441 | | internal static double GetTargetIndex(double requestedTargetIndex, double percent) |
| | | 442 | | { |
| | | 443 | | var normalizedTargetIndex = Math.Clamp(requestedTargetIndex, 0.8d, 0.9d); |
| | | 444 | | var safetyAdjustment = Math.Max(percent, 0d) / 1000d; |
| | | 445 | | return Math.Clamp(normalizedTargetIndex - safetyAdjustment, 0.8d, 0.9d); |
| | | 446 | | } |
| | | 447 | | |
| | | 448 | | private async Task<ProductPriceDetailsItemResponse?> GetProductPriceDetails(ProductPriceItemResponse productPriceIte |
| | | 449 | | { |
| | | 450 | | if (string.IsNullOrWhiteSpace(productPriceItemResponse.OfferId)) |
| | | 451 | | return null; |
| | | 452 | | |
| | | 453 | | var attributes = await _ozonApiDataManager!.GetProductAttributesAsync( |
| | | 454 | | [], |
| | | 455 | | [productPriceItemResponse.OfferId], |
| | | 456 | | null, |
| | | 457 | | limit: 1, |
| | | 458 | | sortBy: "offer_id", |
| | | 459 | | sortDir: "ASC"); |
| | | 460 | | |
| | | 461 | | var skuValue = (attributes.Result ?? Enumerable.Empty<ProductAttributesItemResponse>()) |
| | | 462 | | .FirstOrDefault(x => string.Equals(x.OfferId, productPriceItemResponse.OfferId, StringComparison.Ordinal))?. |
| | | 463 | | |
| | | 464 | | if (!long.TryParse(skuValue, out var sku) || sku <= 0) |
| | | 465 | | return null; |
| | | 466 | | |
| | | 467 | | var priceDetails = await _ozonApiDataManager!.GetProductPricesDetailsAsync([sku]); |
| | | 468 | | return (priceDetails.Prices ?? Enumerable.Empty<ProductPriceDetailsItemResponse>()) |
| | | 469 | | .FirstOrDefault(x => x.Sku == sku); |
| | | 470 | | } |
| | | 471 | | |
| | | 472 | | private void EnsureClient(OzonClient ozonClient) |
| | | 473 | | { |
| | | 474 | | var clientId = ozonClient.DecryptClientId ?? string.Empty; |
| | | 475 | | var apiKey = ozonClient.DecryptApiKey ?? string.Empty; |
| | | 476 | | if (_ozonApiDataManager == null) |
| | | 477 | | { |
| | | 478 | | _ozonApiDataManager = new OzonApiDataManager(_httpClientFactory, _emailService, clientId, apiKey); |
| | | 479 | | } |
| | | 480 | | else |
| | | 481 | | { |
| | | 482 | | _ozonApiDataManager.SetClient(clientId, apiKey); |
| | | 483 | | } |
| | | 484 | | } |
| | | 485 | | |
| | | 486 | | public double GetTruePrice(double MarketingPrice, double ConstantIndex) |
| | | 487 | | { |
| | | 488 | | return MarketingPrice * Math.Round(ConstantIndex, 2); |
| | | 489 | | } |
| | | 490 | | |
| | | 491 | | private static double GetTruePriceStatic(double marketingPrice, double constantIndex) |
| | | 492 | | { |
| | | 493 | | return marketingPrice * Math.Round(constantIndex, 2); |
| | | 494 | | } |
| | | 495 | | |
| | | 496 | | internal double ApplyPercent(double price, double percent) |
| | | 497 | | { |
| | | 498 | | return price * (1 - percent / 100); |
| | | 499 | | } |
| | | 500 | | |
| | | 501 | | /// <summary> |
| | | 502 | | /// Возвращает скорректированную «старую» цену, чтобы разница между старой и новой |
| | | 503 | | /// ценой соответствовала минимальным требованиям Ozon по ценовым уровням. |
| | | 504 | | /// Вынесено как internal для тестирования без обращения к API. |
| | | 505 | | /// </summary> |
| | | 506 | | internal static double ComputeMinOldPrice(double newPrice, double currentOldPrice) |
| | | 507 | | { |
| | | 508 | | var roundedNewPrice = Math.Round(newPrice, MidpointRounding.AwayFromZero); |
| | | 509 | | |
| | | 510 | | if (currentOldPrice <= 0) |
| | | 511 | | return currentOldPrice; |
| | | 512 | | |
| | | 513 | | double minDifference; |
| | | 514 | | if (roundedNewPrice < 400) |
| | | 515 | | minDifference = 20; |
| | | 516 | | else if (roundedNewPrice <= 10000) |
| | | 517 | | minDifference = roundedNewPrice * 0.05; |
| | | 518 | | else |
| | | 519 | | minDifference = 500; |
| | | 520 | | |
| | | 521 | | return currentOldPrice - roundedNewPrice < minDifference |
| | | 522 | | ? roundedNewPrice + minDifference |
| | | 523 | | : currentOldPrice; |
| | | 524 | | } |
| | | 525 | | |
| | | 526 | | private async Task<ProductImportPricesResponse> SetUserPrice( |
| | | 527 | | ProductPriceItemResponse productPriceItemResponse, |
| | | 528 | | OzonClient ozonClient, |
| | | 529 | | double YourPrice, double OldPrice, |
| | | 530 | | double MinPrice, double CostPrice) |
| | | 531 | | { |
| | | 532 | | EnsureClient(ozonClient); |
| | | 533 | | var request = new List<ProductPriceItem> |
| | | 534 | | { |
| | | 535 | | new ProductPriceItem |
| | | 536 | | { |
| | | 537 | | OfferId = productPriceItemResponse.OfferId, |
| | | 538 | | ProductId = productPriceItemResponse.ProductId, |
| | | 539 | | NetPrice = Math.Round(CostPrice, MidpointRounding.AwayFromZero).ToString(CultureInfo.InvariantCulture), |
| | | 540 | | MinPrice = Math.Round(MinPrice, MidpointRounding.AwayFromZero).ToString(CultureInfo.InvariantCulture), |
| | | 541 | | Price = Math.Round(YourPrice, MidpointRounding.AwayFromZero).ToString(CultureInfo.InvariantCulture), |
| | | 542 | | OldPrice = Math.Round(OldPrice, MidpointRounding.AwayFromZero).ToString(CultureInfo.InvariantCulture), |
| | | 543 | | Vat = productPriceItemResponse.Price.Vat.ToString(CultureInfo.InvariantCulture), |
| | | 544 | | } |
| | | 545 | | }; |
| | | 546 | | |
| | | 547 | | var response = await _ozonApiDataManager!.ImportProductPrices(request); |
| | | 548 | | return response; |
| | | 549 | | } |
| | | 550 | | |
| | | 551 | | private async Task<ProductImportPricesResponse> SetNewPrice( |
| | | 552 | | ProductPriceItemResponse productPriceItemResponse, |
| | | 553 | | double newPrice, |
| | | 554 | | OzonClient ozonClient) |
| | | 555 | | { |
| | | 556 | | EnsureClient(ozonClient); |
| | | 557 | | |
| | | 558 | | var roundedNewPrice = Math.Round(newPrice, MidpointRounding.AwayFromZero); |
| | | 559 | | var oldPrice = ComputeMinOldPrice(roundedNewPrice, productPriceItemResponse.Price.MarketingSellerPrice); |
| | | 560 | | |
| | | 561 | | |
| | | 562 | | var request = new List<ProductPriceItem> |
| | | 563 | | { |
| | | 564 | | new ProductPriceItem |
| | | 565 | | { |
| | | 566 | | OfferId = productPriceItemResponse.OfferId, |
| | | 567 | | ProductId = productPriceItemResponse.ProductId, |
| | | 568 | | NetPrice = Math.Round(productPriceItemResponse.Price.NetPrice, MidpointRounding.AwayFromZero) |
| | | 569 | | .ToString(CultureInfo.InvariantCulture), |
| | | 570 | | MinPrice = roundedNewPrice.ToString(CultureInfo.InvariantCulture), |
| | | 571 | | Price = roundedNewPrice.ToString(CultureInfo.InvariantCulture), |
| | | 572 | | OldPrice = oldPrice > 0 ? oldPrice.ToString(CultureInfo.InvariantCulture) : null, |
| | | 573 | | Vat = productPriceItemResponse.Price.Vat.ToString(CultureInfo.InvariantCulture), |
| | | 574 | | } |
| | | 575 | | }; |
| | | 576 | | |
| | | 577 | | var response = await _ozonApiDataManager!.ImportProductPrices(request); |
| | | 578 | | return response; |
| | | 579 | | } |
| | | 580 | | |
| | | 581 | | } |
| | | 582 | | |
| | | 583 | | public sealed class PriceDumpingPreview |
| | | 584 | | { |
| | | 585 | | public double CurrentSellerPrice { get; set; } |
| | | 586 | | public double CurrentCustomerPrice { get; set; } |
| | | 587 | | public double CustomerPriceFactor { get; set; } |
| | | 588 | | public double TargetCustomerPrice { get; set; } |
| | | 589 | | public double TargetSellerPrice { get; set; } |
| | | 590 | | public double TargetIndex { get; set; } |
| | | 591 | | public string Source { get; set; } = string.Empty; |
| | | 592 | | public double SourceIndexValue { get; set; } |
| | | 593 | | public double SourceMinPrice { get; set; } |
| | | 594 | | public decimal DiscountPercent { get; set; } |
| | | 595 | | public string CurrentPriceExplanation { get; set; } = string.Empty; |
| | | 596 | | public string TargetPriceExplanation { get; set; } = string.Empty; |
| | | 597 | | public string CandidatesExplanation { get; set; } = string.Empty; |
| | | 598 | | } |
| | | 599 | | |
| | | 600 | | internal sealed record PriceDumpingCandidate(string Source, double IndexValue, double SourcePrice, double TargetCustomer |
| | | 601 | | internal sealed record PriceDumpingCandidatePreview(string Source, double IndexValue, double SourcePrice, double TargetC |
| | 65 | 602 | | internal sealed record CurrentCustomerPriceSource(double Price, string Source, string Explanation); |