< Summary

Information
Class: Services.PriceСlculationServices.OrderPriceManager
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/PriceСlculationServices/OrderPriceManager.cs
Line coverage
69%
Covered lines: 173
Uncovered lines: 76
Coverable lines: 249
Total lines: 520
Line coverage: 69.4%
Branch coverage
63%
Covered branches: 110
Total branches: 172
Branch coverage: 63.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%11100%
SetPurchasePriceToRUB()87.09%323190.47%
CalculateProfit()76.47%343492.5%
GetProfitBasePrice(...)100%66100%
CalculateDiscount()46.66%843060.97%
LogDiscountValue(...)83.33%66100%
CalculateCostPrice()68.18%252281.48%
GetValidatedCurrencyRateAsync()55.55%13964.28%
ValidateCostFactor(...)83.33%6685.71%
SafeCalculate(...)50%9431.57%
NormalizeDecimal(...)50%3242.85%
LogRecalculationSkipped()0%4260%
LogIfAbnormal()16.66%861220%

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    {
 012        private const decimal MaxReasonableValue = 10_000_000m;
 013        private const decimal MinReasonableRate = 0.0001m;
 014        private const decimal MaxReasonableRate = 1000m;
 15
 16        private readonly ICurrencyRateFetcher _currencyRateFetcher;
 17        private readonly IEmailService _emailService;
 18        private readonly ILogger<OrderPriceManager> _logger;
 19
 3220        public OrderPriceManager(
 3221            ICurrencyRateFetcher currencyRateFetcher,
 3222            IEmailService emailService,
 3223            ILogger<OrderPriceManager> logger)
 24        {
 3225            _currencyRateFetcher = currencyRateFetcher;
 3226            _emailService = emailService;
 3227            _logger = logger;
 3228        }
 29
 30        public async Task<Order?> SetPurchasePriceToRUB(Order? order)
 31        {
 1032            if (order is not Order o || o.Supplier is not Supplier supplier)
 33            {
 234                _logger.LogDebug("Order or Supplier is null in SetPurchasePriceToRUB for order {OrderId}", order?.Id);
 235                return order;
 36            }
 37
 38            try
 39            {
 840                if ((o.PurchasePrice <= 0 || o.PurchasePrice == null) && (o.OriginalPurchasePrice ?? 0) <= 0)
 41                {
 142                    _logger.LogDebug(
 143                        "No PurchasePrice to convert to RUB for order {OrderId}: PurchasePrice={PurchasePrice}, Original
 144                        o.Id, o.PurchasePrice, o.OriginalPurchasePrice);
 145                    return o;
 46                }
 47
 748                if (supplier.CurrencyCode == CurrencyCode.NON)
 49                {
 150                    _logger.LogDebug("Supplier CurrencyCode is NON for order {OrderId}", o.Id);
 151                    return o;
 52                }
 53
 654                decimal rate = 1;
 655                bool shouldConvert = false;
 56
 657                switch (supplier.CurrencyCode)
 58                {
 59                    case OzonDomains.CurrencyCode.USD:
 60                    case OzonDomains.CurrencyCode.EUR:
 61                    case OzonDomains.CurrencyCode.BYN:
 562                        if (!o.OriginalPurchasePrice.HasValue || o.OriginalPurchasePrice == 0)
 63                        {
 564                            o.OriginalPurchasePrice = o.PurchasePrice;
 65                        }
 66
 567                        rate = await GetValidatedCurrencyRateAsync(supplier.CurrencyCode);
 368                        shouldConvert = true;
 369                        break;
 70
 71                    case OzonDomains.CurrencyCode.RUB:
 172                        if (o.OriginalPurchasePrice == null)
 73                        {
 74                            //await LogRecalculationSkipped(nameof(SetPurchasePriceToRUB), o);
 075                            return o;
 76                        }
 77
 178                        o.OriginalPurchasePrice = null;
 179                        break;
 80
 81                    default:
 082                        _logger.LogWarning(
 083                            $"Unsupported currency code: {supplier.CurrencyCode} for order {o.Id}");
 084                        return o;
 85                }
 86
 487                if (shouldConvert)
 88                {
 389                    if (supplier.CostFactor == null)
 90                    {
 191                        _logger.LogWarning($"Supplier CostFactor is null for order {o.Id}");
 192                        return o;
 93                    }
 94
 295                    ValidateCostFactor(supplier.CostFactor);
 96
 97                    // считаем только от OriginalPurchasePrice
 198                    var basePrice = o.OriginalPurchasePrice ?? 0;
 199                    var costFactor = supplier.CostFactor ?? 1;
 1100                    var calculated = SafeCalculate(
 1101                        () => basePrice * costFactor * rate,
 1102                        $"PurchasePrice calculation for order {o.Id}");
 103
 104                    // отбрасываем дробную часть
 1105                    o.PurchasePrice = Math.Truncate(calculated);
 106
 1107                    await LogIfAbnormal(o.PurchasePrice, nameof(SetPurchasePriceToRUB), o);
 108                }
 109
 2110                return o;
 111            }
 3112            catch (Exception ex)
 113            {
 3114                _logger.LogError(ex, $"Error in SetPurchasePriceToRUB for order {order?.Id}");
 3115                throw new OrderPriceCalculationException("Failed to convert purchase price to RUB", ex);
 116            }
 7117        }
 118
 119
 120        public async Task<Order?> CalculateProfit(Order? order)
 121        {
 11122            if (order is not Order o || o.CostPrice is null || o.CostPrice <= 0)
 123            {
 3124                _logger.LogDebug(
 3125                    "Cannot calculate profit for order {OrderId} without valid CostPrice: Price={Price}, CurrentPriceWit
 3126                    order?.Id, order?.Price, order?.ProductInfo?.CurrentPriceWithDiscount, order?.CostPrice);
 3127                return order;
 128            }
 129
 130            try
 131            {
 8132                o.MaxProfit = null;
 8133                o.MinProfit = null;
 134
 135                // Проверяем, что оба значения существуют перед расчетом
 8136                var price = GetProfitBasePrice(o);
 8137                if (!price.HasValue || !o.CostPrice.HasValue)
 138                {
 1139                    _logger.LogDebug(
 1140                        "Missing price data for order {OrderId} - Price: {Price}, CurrentPriceWithDiscount: {CurrentPric
 1141                        o.Id, o.Price, o.ProductInfo?.CurrentPriceWithDiscount, o.CostPrice);
 1142                    return o;
 143                }
 144
 7145                var costPrice = o.CostPrice.Value;
 146
 7147                decimal startCommission = SafeCalculate(
 7148                    () => price - costPrice,
 7149                    $"StartCommission calculation for order {o.Id}");
 150
 7151                if (startCommission < 0)
 152                {
 3153                    _logger.LogDebug("Negative start commission ({StartCommission}) for order {OrderId}", startCommissio
 154                }
 155
 7156                await LogIfAbnormal(startCommission, $"{nameof(CalculateProfit)}_StartCommission", o);
 157
 158                // Расчет максимальной прибыли (с минимальной комиссией Ozon)
 7159                if (o.MinOzonCommission.HasValue)
 160                {
 6161                    var minOzonCommission = o.MinOzonCommission.Value;
 6162                    o.MaxProfit = SafeCalculate(
 6163                        () => startCommission - minOzonCommission,
 6164                        $"MaxProfit calculation for order {o.Id}");
 165
 6166                    if (o.MaxProfit < 0)
 167                    {
 5168                        _logger.LogDebug("Negative MaxProfit ({MaxProfit}) for order {OrderId}", o.MaxProfit, o.Id);
 169                    }
 170
 6171                    await LogIfAbnormal(o.MaxProfit, $"{nameof(CalculateProfit)}_MaxProfit", o);
 172                }
 173                else
 174                {
 1175                    _logger.LogDebug($"MinOzonCommission not set for order {o.Id}");
 176                }
 177
 178                // Расчет минимальной прибыли (с максимальной комиссией Ozon)
 7179                if (o.MaxOzonCommission.HasValue)
 180                {
 6181                    var maxOzonCommission = o.MaxOzonCommission.Value;
 6182                    o.MinProfit = SafeCalculate(
 6183                        () => startCommission - maxOzonCommission,
 6184                        $"MinProfit calculation for order {o.Id}");
 185
 6186                    if (o.MinProfit < 0)
 187                    {
 5188                        _logger.LogDebug("Negative MinProfit ({MinProfit}) for order {OrderId}", o.MinProfit, o.Id);
 189                    }
 190
 6191                    await LogIfAbnormal(o.MinProfit, $"{nameof(CalculateProfit)}_MinProfit", o);
 192                }
 193                else
 194                {
 1195                    _logger.LogDebug($"MaxOzonCommission not set for order {o.Id}");
 196                }
 197
 7198                return o;
 199            }
 0200            catch (Exception ex)
 201            {
 0202                _logger.LogError(ex, $"Error in CalculateProfit for order {order?.Id}");
 0203                throw new OrderPriceCalculationException("Failed to calculate profit", ex);
 204            }
 11205        }
 206
 207        /// <summary>
 208        /// Возвращает цену сайта для планового расчёта прибыли. Если актуальная цена
 209        /// ещё не загружена, используем цену заказа как совместимый fallback.
 210        /// </summary>
 211        private static decimal? GetProfitBasePrice(Order order)
 212        {
 8213            var currentPrice = order.ProductInfo?.CurrentPriceWithDiscount;
 8214            return currentPrice is > 0 ? currentPrice : order.Price;
 215        }
 216
 217        public async Task<Order?> CalculateDiscount(Order? order)
 218        {
 5219            if (order is not Order o || o.CostPrice is null || o.CostPrice <= 0)
 220            {
 3221                _logger.LogDebug(
 3222                    "Cannot calculate discount for order {OrderId} without valid CostPrice: {CostPrice}",
 3223                    order?.Id, order?.CostPrice);
 3224                return order;
 225            }
 226
 227            try
 228            {
 2229                o.MaxDiscount = null;
 2230                o.MinDiscount = null;
 231
 232                // Проверка на наличие CostPrice.Value перед расчетами
 2233                if (!o.CostPrice.HasValue || o.CostPrice.Value <= 0)
 234                {
 0235                    _logger.LogDebug(
 0236                        "Invalid CostPrice value for order {OrderId}: {CostPrice}",
 0237                        o.Id, o.CostPrice);
 0238                    return o;
 239                }
 240
 2241                decimal costPrice = o.CostPrice.Value;
 242
 243                // Расчет минимальной скидки
 2244                if (o.MinProfit.HasValue)
 245                {
 2246                    if (costPrice == 0)
 247                    {
 0248                        _logger.LogError($"Division by zero avoided (CostPrice=0) for order {o.Id}");
 0249                        return o;
 250                    }
 251
 2252                    var minProfit = o.MinProfit.Value;
 2253                    o.MinDiscount = SafeCalculate(
 2254                        () => (minProfit / costPrice) * 100,
 2255                        $"MinDiscount calculation for order {o.Id}");
 256
 2257                    LogDiscountValue(o.MinDiscount, "MinDiscount", o.Id.ToString());
 258
 2259                    await LogIfAbnormal(o.MinDiscount, $"{nameof(CalculateDiscount)}_MinDiscount", o);
 260                }
 261                else
 262                {
 0263                    _logger.LogDebug($"MinProfit not available for order {o.Id}");
 264                }
 265
 266                // Расчет максимальной скидки
 2267                if (o.MaxProfit.HasValue)
 268                {
 2269                    if (costPrice == 0)
 270                    {
 0271                        _logger.LogError($"Division by zero avoided (CostPrice=0) for order {o.Id}");
 0272                        return o;
 273                    }
 274
 2275                    var maxProfit = o.MaxProfit.Value;
 2276                    o.MaxDiscount = SafeCalculate(
 2277                        () => (maxProfit / costPrice) * 100,
 2278                        $"MaxDiscount calculation for order {o.Id}");
 279
 2280                    LogDiscountValue(o.MaxDiscount, "MaxDiscount", o.Id.ToString());
 281
 2282                    await LogIfAbnormal(o.MaxDiscount, $"{nameof(CalculateDiscount)}_MaxDiscount", o);
 283                }
 284                else
 285                {
 0286                    _logger.LogDebug($"MaxProfit not available for order {o.Id}");
 287                }
 288
 2289                return o;
 290            }
 0291            catch (Exception ex)
 292            {
 0293                _logger.LogError(ex, $"Error in CalculateDiscount for order {order?.Id}. " +
 0294                                     $"CostPrice: {order?.CostPrice}, " +
 0295                                     $"MinProfit: {order?.MinProfit}, " +
 0296                                     $"MaxProfit: {order?.MaxProfit}");
 0297                throw new OrderPriceCalculationException("Failed to calculate discount", ex);
 298            }
 5299        }
 300
 301        private void LogDiscountValue(decimal? discount, string discountType, string? orderId)
 302        {
 4303            if (!discount.HasValue) return;
 304
 4305            if (discount.Value < 0)
 306            {
 2307                _logger.LogDebug("Negative {DiscountType} ({Discount}%) for order {OrderId}", discountType, discount.Val
 308            }
 2309            else if (discount.Value > 100)
 310            {
 2311                _logger.LogDebug("Unusually high {DiscountType} ({Discount}%) for order {OrderId}", discountType, discou
 312            }
 2313        }
 314
 315        public async Task<Order?> CalculateCostPrice(Order? order)
 316        {
 6317            if (order is not Order o || o.Supplier is not Supplier supplier || o.ProductInfo is not Product productInfo)
 318            {
 3319                _logger.LogDebug("Cannot calculate cost price for order {OrderId} without Order/Supplier/ProductInfo", o
 3320                return order;
 321            }
 322
 323            try
 324            {
 3325                decimal weightFactorInRub = 0;
 326
 3327                if (supplier.WeightFactor > 0)
 328                {
 1329                    var currencyRate = supplier.WeightFactorCurrencyCode != OzonDomains.CurrencyCode.RUB
 1330                        ? await GetValidatedCurrencyRateAsync(supplier.WeightFactorCurrencyCode)
 1331                        : 1;
 332
 1333                    weightFactorInRub = SafeCalculate(
 1334                        () => supplier.WeightFactor * currencyRate,
 1335                        $"WeightFactor calculation for order {o.Id}");
 1336                }
 337
 3338                if (!o.PurchasePrice.HasValue)
 339                {
 2340                    _logger.LogDebug("No valid price data available for order {OrderId}", o.Id);
 2341                    return o;
 342                }
 343
 1344                if (supplier.WeightFactor > 0 && productInfo.Weight.HasValue)
 345                {
 1346                    var productWeight = productInfo.Weight.Value;
 1347                    o.CostPrice = SafeCalculate(
 1348                        () => (productWeight * weightFactorInRub) + o.PurchasePrice.Value,
 1349                        $"CostPrice calculation with weight for order {o.Id}");
 350                }
 351                else
 352                {
 0353                    o.CostPrice = NormalizeDecimal(o.PurchasePrice.Value);
 354                }
 355
 1356                if (o.CostPrice.HasValue)
 357                {
 1358                    await LogIfAbnormal(o.CostPrice.Value, nameof(CalculateCostPrice), o);
 359                }
 360                else
 361                {
 0362                    _logger.LogWarning("CostPrice calculation failed for order {OrderId}", o.Id);
 363                }
 364
 1365                return o;
 366            }
 0367            catch (Exception ex)
 368            {
 0369                _logger.LogError(ex, "Error in CalculateCostPrice for order {OrderId}", order?.Id);
 0370                throw new OrderPriceCalculationException("Failed to calculate cost price", ex);
 371            }
 6372        }
 373
 374        private async Task<decimal> GetValidatedCurrencyRateAsync(OzonDomains.CurrencyCode currencyCode)
 375        {
 5376            decimal rate = currencyCode switch
 5377            {
 5378                OzonDomains.CurrencyCode.USD => await _currencyRateFetcher.GetUSDRateAsync(),
 0379                OzonDomains.CurrencyCode.EUR => await _currencyRateFetcher.GetEURRateAsync(),
 0380                OzonDomains.CurrencyCode.BYN => await _currencyRateFetcher.GetBYNRateAsync(),
 0381                OzonDomains.CurrencyCode.RUB => 1,
 0382                _ => throw new ArgumentOutOfRangeException(nameof(currencyCode),
 0383                    $"Unsupported currency: {currencyCode}")
 5384            };
 385
 5386            if (rate < MinReasonableRate || rate > MaxReasonableRate)
 387            {
 2388                throw new InvalidOperationException(
 2389                    $"Abnormal currency rate {rate} for {currencyCode}. Expected between {MinReasonableRate} and {MaxRea
 390            }
 391
 3392            return rate;
 3393        }
 394
 395        private void ValidateCostFactor(decimal? costFactor)
 396        {
 2397            if (!costFactor.HasValue)
 398            {
 0399                throw new ArgumentNullException(nameof(costFactor), "Cost factor cannot be null");
 400            }
 401
 2402            if (costFactor <= 0 || costFactor > 100)
 403            {
 1404                throw new ArgumentOutOfRangeException(
 1405                    nameof(costFactor),
 1406                    $"Invalid cost factor {costFactor}. Expected value between 0 and 100");
 407            }
 1408        }
 409
 410        private decimal SafeCalculate(Func<decimal?> calculation, string context)
 411        {
 412            try
 413            {
 414                checked
 415                {
 26416                    decimal? result = calculation();
 417
 26418                    if (!result.HasValue)
 419                    {
 0420                        throw new InvalidOperationException(
 0421                            $"Calculation returned null in context: {context}");
 422                    }
 423
 26424                    decimal normalized = NormalizeDecimal(result.Value);
 425
 26426                    if (Math.Abs(normalized) > MaxReasonableValue)
 427                    {
 0428                        _logger.LogError(
 0429                            $"Calculated value {normalized} exceeds reasonable limit {MaxReasonableValue} in {context}")
 430                    }
 431
 26432                    return normalized;
 433                }
 434            }
 0435            catch (OverflowException ex)
 436            {
 0437                _logger.LogError(ex, $"Overflow in calculation: {context}");
 0438                throw;
 439            }
 0440            catch (DivideByZeroException ex)
 441            {
 0442                _logger.LogError(ex, $"Division by zero in: {context}");
 0443                throw;
 444            }
 0445            catch (NullReferenceException ex)
 446            {
 0447                _logger.LogError(ex, $"Null reference in calculation: {context}");
 0448                throw new InvalidOperationException("Calculation returned null value", ex);
 449            }
 26450        }
 451
 452        private decimal NormalizeDecimal(decimal? value)
 453        {
 26454            if (!value.HasValue)
 0455                return 0;
 456
 457            try
 458            {
 26459                return Math.Round(value.Value, 2, MidpointRounding.AwayFromZero);
 460            }
 0461            catch (ArgumentOutOfRangeException ex)
 462            {
 0463                _logger.LogError(ex, $"Rounding error occurred for value: {value}");
 0464                return value.Value;
 465            }
 26466        }
 467
 468        private async Task LogRecalculationSkipped(string context, Order? order)
 469        {
 0470            string message =
 0471                $"Пересчёт пропущен в {context}\n" +
 0472                $"Заказ: {order?.Print() ?? "NULL_ORDER"}";
 473
 474            try
 475            {
 0476                await _emailService.SendEmailAsync("Пересчёт пропущен", message);
 0477                _logger.LogInformation($"Залогировано событие 'пересчёт пропущен': {message}");
 0478            }
 0479            catch (Exception ex)
 480            {
 0481                _logger.LogError(ex, $"Не удалось залогировать событие 'пересчёт пропущен' для заказа {order?.Id}");
 0482            }
 0483        }
 484
 485
 486        private async Task LogIfAbnormal(decimal? value, string context, Order? order)
 487        {
 25488            if (!value.HasValue)
 489            {
 0490                _logger.LogWarning($"Обнаружено пустое значение в {context} для заказа {order?.Id}");
 0491                return;
 492            }
 493
 25494            if (Math.Abs(value.Value) >= MaxReasonableValue)
 495            {
 0496                string message =
 0497                    $"Обнаружено аномальное значение в {context}: {value.Value.ToString(CultureInfo.InvariantCulture)}\n
 0498                    $"Заказ: {order?.Print() ?? "NULL_ORDER"}";
 499
 500                try
 501                {
 0502                    await _emailService.SendEmailAsync("Обнаружено аномальное значение", message);
 0503                    _logger.LogWarning($"Отправлено предупреждение об аномальном значении: {message}");
 0504                }
 0505                catch (Exception ex)
 506                {
 0507                    _logger.LogError(ex, $"Не удалось отправить письмо об аномальном значении для заказа {order?.Id}");
 0508                }
 0509            }
 25510        }
 511    }
 512
 513    public class OrderPriceCalculationException : Exception
 514    {
 515        public OrderPriceCalculationException(string message, Exception innerException)
 516            : base(message, innerException)
 517        {
 518        }
 519    }
 520}