< Summary

Information
Class: Services.PriceСlculationServices.OrderPriceCalculationException
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/PriceСlculationServices/OrderPriceManager.cs
Line coverage
100%
Covered lines: 2
Uncovered lines: 0
Coverable lines: 2
Total lines: 520
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
.ctor(...)100%11100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/PriceСlculationServices/OrderPriceManager.cs

#LineLine coverage
 1using System.Globalization;
 2using Microsoft.Extensions.Logging;
 3using OzonDomains;
 4using OzonDomains.Models;
 5using OzonDomains.Models.Extensions;
 6using SlqStudio.Application.Services.EmailService;
 7
 8namespace Services.PriceСlculationServices
 9{
 10    public class OrderPriceManager
 11    {
 12        private const decimal MaxReasonableValue = 10_000_000m;
 13        private const decimal MinReasonableRate = 0.0001m;
 14        private const decimal MaxReasonableRate = 1000m;
 15
 16        private readonly ICurrencyRateFetcher _currencyRateFetcher;
 17        private readonly IEmailService _emailService;
 18        private readonly ILogger<OrderPriceManager> _logger;
 19
 20        public OrderPriceManager(
 21            ICurrencyRateFetcher currencyRateFetcher,
 22            IEmailService emailService,
 23            ILogger<OrderPriceManager> logger)
 24        {
 25            _currencyRateFetcher = currencyRateFetcher;
 26            _emailService = emailService;
 27            _logger = logger;
 28        }
 29
 30        public async Task<Order?> SetPurchasePriceToRUB(Order? order)
 31        {
 32            if (order is not Order o || o.Supplier is not Supplier supplier)
 33            {
 34                _logger.LogDebug("Order or Supplier is null in SetPurchasePriceToRUB for order {OrderId}", order?.Id);
 35                return order;
 36            }
 37
 38            try
 39            {
 40                if ((o.PurchasePrice <= 0 || o.PurchasePrice == null) && (o.OriginalPurchasePrice ?? 0) <= 0)
 41                {
 42                    _logger.LogDebug(
 43                        "No PurchasePrice to convert to RUB for order {OrderId}: PurchasePrice={PurchasePrice}, Original
 44                        o.Id, o.PurchasePrice, o.OriginalPurchasePrice);
 45                    return o;
 46                }
 47
 48                if (supplier.CurrencyCode == CurrencyCode.NON)
 49                {
 50                    _logger.LogDebug("Supplier CurrencyCode is NON for order {OrderId}", o.Id);
 51                    return o;
 52                }
 53
 54                decimal rate = 1;
 55                bool shouldConvert = false;
 56
 57                switch (supplier.CurrencyCode)
 58                {
 59                    case OzonDomains.CurrencyCode.USD:
 60                    case OzonDomains.CurrencyCode.EUR:
 61                    case OzonDomains.CurrencyCode.BYN:
 62                        if (!o.OriginalPurchasePrice.HasValue || o.OriginalPurchasePrice == 0)
 63                        {
 64                            o.OriginalPurchasePrice = o.PurchasePrice;
 65                        }
 66
 67                        rate = await GetValidatedCurrencyRateAsync(supplier.CurrencyCode);
 68                        shouldConvert = true;
 69                        break;
 70
 71                    case OzonDomains.CurrencyCode.RUB:
 72                        if (o.OriginalPurchasePrice == null)
 73                        {
 74                            //await LogRecalculationSkipped(nameof(SetPurchasePriceToRUB), o);
 75                            return o;
 76                        }
 77
 78                        o.OriginalPurchasePrice = null;
 79                        break;
 80
 81                    default:
 82                        _logger.LogWarning(
 83                            $"Unsupported currency code: {supplier.CurrencyCode} for order {o.Id}");
 84                        return o;
 85                }
 86
 87                if (shouldConvert)
 88                {
 89                    if (supplier.CostFactor == null)
 90                    {
 91                        _logger.LogWarning($"Supplier CostFactor is null for order {o.Id}");
 92                        return o;
 93                    }
 94
 95                    ValidateCostFactor(supplier.CostFactor);
 96
 97                    // считаем только от OriginalPurchasePrice
 98                    var basePrice = o.OriginalPurchasePrice ?? 0;
 99                    var costFactor = supplier.CostFactor ?? 1;
 100                    var calculated = SafeCalculate(
 101                        () => basePrice * costFactor * rate,
 102                        $"PurchasePrice calculation for order {o.Id}");
 103
 104                    // отбрасываем дробную часть
 105                    o.PurchasePrice = Math.Truncate(calculated);
 106
 107                    await LogIfAbnormal(o.PurchasePrice, nameof(SetPurchasePriceToRUB), o);
 108                }
 109
 110                return o;
 111            }
 112            catch (Exception ex)
 113            {
 114                _logger.LogError(ex, $"Error in SetPurchasePriceToRUB for order {order?.Id}");
 115                throw new OrderPriceCalculationException("Failed to convert purchase price to RUB", ex);
 116            }
 117        }
 118
 119
 120        public async Task<Order?> CalculateProfit(Order? order)
 121        {
 122            if (order is not Order o || o.CostPrice is null || o.CostPrice <= 0)
 123            {
 124                _logger.LogDebug(
 125                    "Cannot calculate profit for order {OrderId} without valid CostPrice: Price={Price}, CurrentPriceWit
 126                    order?.Id, order?.Price, order?.ProductInfo?.CurrentPriceWithDiscount, order?.CostPrice);
 127                return order;
 128            }
 129
 130            try
 131            {
 132                o.MaxProfit = null;
 133                o.MinProfit = null;
 134
 135                // Проверяем, что оба значения существуют перед расчетом
 136                var price = GetProfitBasePrice(o);
 137                if (!price.HasValue || !o.CostPrice.HasValue)
 138                {
 139                    _logger.LogDebug(
 140                        "Missing price data for order {OrderId} - Price: {Price}, CurrentPriceWithDiscount: {CurrentPric
 141                        o.Id, o.Price, o.ProductInfo?.CurrentPriceWithDiscount, o.CostPrice);
 142                    return o;
 143                }
 144
 145                var costPrice = o.CostPrice.Value;
 146
 147                decimal startCommission = SafeCalculate(
 148                    () => price - costPrice,
 149                    $"StartCommission calculation for order {o.Id}");
 150
 151                if (startCommission < 0)
 152                {
 153                    _logger.LogDebug("Negative start commission ({StartCommission}) for order {OrderId}", startCommissio
 154                }
 155
 156                await LogIfAbnormal(startCommission, $"{nameof(CalculateProfit)}_StartCommission", o);
 157
 158                // Расчет максимальной прибыли (с минимальной комиссией Ozon)
 159                if (o.MinOzonCommission.HasValue)
 160                {
 161                    var minOzonCommission = o.MinOzonCommission.Value;
 162                    o.MaxProfit = SafeCalculate(
 163                        () => startCommission - minOzonCommission,
 164                        $"MaxProfit calculation for order {o.Id}");
 165
 166                    if (o.MaxProfit < 0)
 167                    {
 168                        _logger.LogDebug("Negative MaxProfit ({MaxProfit}) for order {OrderId}", o.MaxProfit, o.Id);
 169                    }
 170
 171                    await LogIfAbnormal(o.MaxProfit, $"{nameof(CalculateProfit)}_MaxProfit", o);
 172                }
 173                else
 174                {
 175                    _logger.LogDebug($"MinOzonCommission not set for order {o.Id}");
 176                }
 177
 178                // Расчет минимальной прибыли (с максимальной комиссией Ozon)
 179                if (o.MaxOzonCommission.HasValue)
 180                {
 181                    var maxOzonCommission = o.MaxOzonCommission.Value;
 182                    o.MinProfit = SafeCalculate(
 183                        () => startCommission - maxOzonCommission,
 184                        $"MinProfit calculation for order {o.Id}");
 185
 186                    if (o.MinProfit < 0)
 187                    {
 188                        _logger.LogDebug("Negative MinProfit ({MinProfit}) for order {OrderId}", o.MinProfit, o.Id);
 189                    }
 190
 191                    await LogIfAbnormal(o.MinProfit, $"{nameof(CalculateProfit)}_MinProfit", o);
 192                }
 193                else
 194                {
 195                    _logger.LogDebug($"MaxOzonCommission not set for order {o.Id}");
 196                }
 197
 198                return o;
 199            }
 200            catch (Exception ex)
 201            {
 202                _logger.LogError(ex, $"Error in CalculateProfit for order {order?.Id}");
 203                throw new OrderPriceCalculationException("Failed to calculate profit", ex);
 204            }
 205        }
 206
 207        /// <summary>
 208        /// Возвращает цену сайта для планового расчёта прибыли. Если актуальная цена
 209        /// ещё не загружена, используем цену заказа как совместимый fallback.
 210        /// </summary>
 211        private static decimal? GetProfitBasePrice(Order order)
 212        {
 213            var currentPrice = order.ProductInfo?.CurrentPriceWithDiscount;
 214            return currentPrice is > 0 ? currentPrice : order.Price;
 215        }
 216
 217        public async Task<Order?> CalculateDiscount(Order? order)
 218        {
 219            if (order is not Order o || o.CostPrice is null || o.CostPrice <= 0)
 220            {
 221                _logger.LogDebug(
 222                    "Cannot calculate discount for order {OrderId} without valid CostPrice: {CostPrice}",
 223                    order?.Id, order?.CostPrice);
 224                return order;
 225            }
 226
 227            try
 228            {
 229                o.MaxDiscount = null;
 230                o.MinDiscount = null;
 231
 232                // Проверка на наличие CostPrice.Value перед расчетами
 233                if (!o.CostPrice.HasValue || o.CostPrice.Value <= 0)
 234                {
 235                    _logger.LogDebug(
 236                        "Invalid CostPrice value for order {OrderId}: {CostPrice}",
 237                        o.Id, o.CostPrice);
 238                    return o;
 239                }
 240
 241                decimal costPrice = o.CostPrice.Value;
 242
 243                // Расчет минимальной скидки
 244                if (o.MinProfit.HasValue)
 245                {
 246                    if (costPrice == 0)
 247                    {
 248                        _logger.LogError($"Division by zero avoided (CostPrice=0) for order {o.Id}");
 249                        return o;
 250                    }
 251
 252                    var minProfit = o.MinProfit.Value;
 253                    o.MinDiscount = SafeCalculate(
 254                        () => (minProfit / costPrice) * 100,
 255                        $"MinDiscount calculation for order {o.Id}");
 256
 257                    LogDiscountValue(o.MinDiscount, "MinDiscount", o.Id.ToString());
 258
 259                    await LogIfAbnormal(o.MinDiscount, $"{nameof(CalculateDiscount)}_MinDiscount", o);
 260                }
 261                else
 262                {
 263                    _logger.LogDebug($"MinProfit not available for order {o.Id}");
 264                }
 265
 266                // Расчет максимальной скидки
 267                if (o.MaxProfit.HasValue)
 268                {
 269                    if (costPrice == 0)
 270                    {
 271                        _logger.LogError($"Division by zero avoided (CostPrice=0) for order {o.Id}");
 272                        return o;
 273                    }
 274
 275                    var maxProfit = o.MaxProfit.Value;
 276                    o.MaxDiscount = SafeCalculate(
 277                        () => (maxProfit / costPrice) * 100,
 278                        $"MaxDiscount calculation for order {o.Id}");
 279
 280                    LogDiscountValue(o.MaxDiscount, "MaxDiscount", o.Id.ToString());
 281
 282                    await LogIfAbnormal(o.MaxDiscount, $"{nameof(CalculateDiscount)}_MaxDiscount", o);
 283                }
 284                else
 285                {
 286                    _logger.LogDebug($"MaxProfit not available for order {o.Id}");
 287                }
 288
 289                return o;
 290            }
 291            catch (Exception ex)
 292            {
 293                _logger.LogError(ex, $"Error in CalculateDiscount for order {order?.Id}. " +
 294                                     $"CostPrice: {order?.CostPrice}, " +
 295                                     $"MinProfit: {order?.MinProfit}, " +
 296                                     $"MaxProfit: {order?.MaxProfit}");
 297                throw new OrderPriceCalculationException("Failed to calculate discount", ex);
 298            }
 299        }
 300
 301        private void LogDiscountValue(decimal? discount, string discountType, string? orderId)
 302        {
 303            if (!discount.HasValue) return;
 304
 305            if (discount.Value < 0)
 306            {
 307                _logger.LogDebug("Negative {DiscountType} ({Discount}%) for order {OrderId}", discountType, discount.Val
 308            }
 309            else if (discount.Value > 100)
 310            {
 311                _logger.LogDebug("Unusually high {DiscountType} ({Discount}%) for order {OrderId}", discountType, discou
 312            }
 313        }
 314
 315        public async Task<Order?> CalculateCostPrice(Order? order)
 316        {
 317            if (order is not Order o || o.Supplier is not Supplier supplier || o.ProductInfo is not Product productInfo)
 318            {
 319                _logger.LogDebug("Cannot calculate cost price for order {OrderId} without Order/Supplier/ProductInfo", o
 320                return order;
 321            }
 322
 323            try
 324            {
 325                decimal weightFactorInRub = 0;
 326
 327                if (supplier.WeightFactor > 0)
 328                {
 329                    var currencyRate = supplier.WeightFactorCurrencyCode != OzonDomains.CurrencyCode.RUB
 330                        ? await GetValidatedCurrencyRateAsync(supplier.WeightFactorCurrencyCode)
 331                        : 1;
 332
 333                    weightFactorInRub = SafeCalculate(
 334                        () => supplier.WeightFactor * currencyRate,
 335                        $"WeightFactor calculation for order {o.Id}");
 336                }
 337
 338                if (!o.PurchasePrice.HasValue)
 339                {
 340                    _logger.LogDebug("No valid price data available for order {OrderId}", o.Id);
 341                    return o;
 342                }
 343
 344                if (supplier.WeightFactor > 0 && productInfo.Weight.HasValue)
 345                {
 346                    var productWeight = productInfo.Weight.Value;
 347                    o.CostPrice = SafeCalculate(
 348                        () => (productWeight * weightFactorInRub) + o.PurchasePrice.Value,
 349                        $"CostPrice calculation with weight for order {o.Id}");
 350                }
 351                else
 352                {
 353                    o.CostPrice = NormalizeDecimal(o.PurchasePrice.Value);
 354                }
 355
 356                if (o.CostPrice.HasValue)
 357                {
 358                    await LogIfAbnormal(o.CostPrice.Value, nameof(CalculateCostPrice), o);
 359                }
 360                else
 361                {
 362                    _logger.LogWarning("CostPrice calculation failed for order {OrderId}", o.Id);
 363                }
 364
 365                return o;
 366            }
 367            catch (Exception ex)
 368            {
 369                _logger.LogError(ex, "Error in CalculateCostPrice for order {OrderId}", order?.Id);
 370                throw new OrderPriceCalculationException("Failed to calculate cost price", ex);
 371            }
 372        }
 373
 374        private async Task<decimal> GetValidatedCurrencyRateAsync(OzonDomains.CurrencyCode currencyCode)
 375        {
 376            decimal rate = currencyCode switch
 377            {
 378                OzonDomains.CurrencyCode.USD => await _currencyRateFetcher.GetUSDRateAsync(),
 379                OzonDomains.CurrencyCode.EUR => await _currencyRateFetcher.GetEURRateAsync(),
 380                OzonDomains.CurrencyCode.BYN => await _currencyRateFetcher.GetBYNRateAsync(),
 381                OzonDomains.CurrencyCode.RUB => 1,
 382                _ => throw new ArgumentOutOfRangeException(nameof(currencyCode),
 383                    $"Unsupported currency: {currencyCode}")
 384            };
 385
 386            if (rate < MinReasonableRate || rate > MaxReasonableRate)
 387            {
 388                throw new InvalidOperationException(
 389                    $"Abnormal currency rate {rate} for {currencyCode}. Expected between {MinReasonableRate} and {MaxRea
 390            }
 391
 392            return rate;
 393        }
 394
 395        private void ValidateCostFactor(decimal? costFactor)
 396        {
 397            if (!costFactor.HasValue)
 398            {
 399                throw new ArgumentNullException(nameof(costFactor), "Cost factor cannot be null");
 400            }
 401
 402            if (costFactor <= 0 || costFactor > 100)
 403            {
 404                throw new ArgumentOutOfRangeException(
 405                    nameof(costFactor),
 406                    $"Invalid cost factor {costFactor}. Expected value between 0 and 100");
 407            }
 408        }
 409
 410        private decimal SafeCalculate(Func<decimal?> calculation, string context)
 411        {
 412            try
 413            {
 414                checked
 415                {
 416                    decimal? result = calculation();
 417
 418                    if (!result.HasValue)
 419                    {
 420                        throw new InvalidOperationException(
 421                            $"Calculation returned null in context: {context}");
 422                    }
 423
 424                    decimal normalized = NormalizeDecimal(result.Value);
 425
 426                    if (Math.Abs(normalized) > MaxReasonableValue)
 427                    {
 428                        _logger.LogError(
 429                            $"Calculated value {normalized} exceeds reasonable limit {MaxReasonableValue} in {context}")
 430                    }
 431
 432                    return normalized;
 433                }
 434            }
 435            catch (OverflowException ex)
 436            {
 437                _logger.LogError(ex, $"Overflow in calculation: {context}");
 438                throw;
 439            }
 440            catch (DivideByZeroException ex)
 441            {
 442                _logger.LogError(ex, $"Division by zero in: {context}");
 443                throw;
 444            }
 445            catch (NullReferenceException ex)
 446            {
 447                _logger.LogError(ex, $"Null reference in calculation: {context}");
 448                throw new InvalidOperationException("Calculation returned null value", ex);
 449            }
 450        }
 451
 452        private decimal NormalizeDecimal(decimal? value)
 453        {
 454            if (!value.HasValue)
 455                return 0;
 456
 457            try
 458            {
 459                return Math.Round(value.Value, 2, MidpointRounding.AwayFromZero);
 460            }
 461            catch (ArgumentOutOfRangeException ex)
 462            {
 463                _logger.LogError(ex, $"Rounding error occurred for value: {value}");
 464                return value.Value;
 465            }
 466        }
 467
 468        private async Task LogRecalculationSkipped(string context, Order? order)
 469        {
 470            string message =
 471                $"Пересчёт пропущен в {context}\n" +
 472                $"Заказ: {order?.Print() ?? "NULL_ORDER"}";
 473
 474            try
 475            {
 476                await _emailService.SendEmailAsync("Пересчёт пропущен", message);
 477                _logger.LogInformation($"Залогировано событие 'пересчёт пропущен': {message}");
 478            }
 479            catch (Exception ex)
 480            {
 481                _logger.LogError(ex, $"Не удалось залогировать событие 'пересчёт пропущен' для заказа {order?.Id}");
 482            }
 483        }
 484
 485
 486        private async Task LogIfAbnormal(decimal? value, string context, Order? order)
 487        {
 488            if (!value.HasValue)
 489            {
 490                _logger.LogWarning($"Обнаружено пустое значение в {context} для заказа {order?.Id}");
 491                return;
 492            }
 493
 494            if (Math.Abs(value.Value) >= MaxReasonableValue)
 495            {
 496                string message =
 497                    $"Обнаружено аномальное значение в {context}: {value.Value.ToString(CultureInfo.InvariantCulture)}\n
 498                    $"Заказ: {order?.Print() ?? "NULL_ORDER"}";
 499
 500                try
 501                {
 502                    await _emailService.SendEmailAsync("Обнаружено аномальное значение", message);
 503                    _logger.LogWarning($"Отправлено предупреждение об аномальном значении: {message}");
 504                }
 505                catch (Exception ex)
 506                {
 507                    _logger.LogError(ex, $"Не удалось отправить письмо об аномальном значении для заказа {order?.Id}");
 508                }
 509            }
 510        }
 511    }
 512
 513    public class OrderPriceCalculationException : Exception
 514    {
 515        public OrderPriceCalculationException(string message, Exception innerException)
 3516            : base(message, innerException)
 517        {
 3518        }
 519    }
 520}