< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.AnalysisServices.ProductGroupCalculator
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/AnalysisServices/ProductGroupCalculator.cs
Line coverage
96%
Covered lines: 87
Uncovered lines: 3
Coverable lines: 90
Total lines: 247
Line coverage: 96.6%
Branch coverage
87%
Covered branches: 49
Total branches: 56
Branch coverage: 87.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
HasProfit(...)100%22100%
AverageProfit(...)100%11100%
IsAnomalousProfit(...)100%22100%
.ctor(...)100%44100%
Normalize(...)92.85%141492.85%
LowerBoundAfter(...)100%44100%
FirstNotBefore(...)100%44100%
Build(...)76.92%262696.29%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/AnalysisServices/ProductGroupCalculator.cs

#LineLine coverage
 1using OzonOrdersWeb.Areas.Studio2.DTOs;
 2
 3namespace OzonOrdersWeb.Areas.Studio2.AnalysisServices;
 4
 5/// <summary>Облегчённая проекция заказа для аналитики: без Include и навигационных графов.</summary>
 6public sealed class AnalyticsOrderRow
 7{
 8    public int Id { get; init; }
 9    public string? ProductKey { get; init; }
 10    public string? Article { get; init; }
 11    public string? ProductName { get; init; }
 12    public string? ManufacturerName { get; init; }
 13    public string? EtProducerName { get; init; }
 14    public string? ClientName { get; init; }
 15    public string? AppStatusName { get; init; }
 16    public string? ShipmentNumber { get; init; }
 17    public string? ClientStatusName { get; init; }
 18    public int? AppStatusId { get; init; }
 19    public DateTime? ShippingDate { get; init; }
 20    public bool IsReturnable { get; init; }
 21    public DateTime? ProcessingDate { get; init; }
 22    public int? Quantity { get; init; }
 23    public decimal? Price { get; init; }
 24    public decimal? ShipmentAmount { get; init; }
 25    public decimal? CostPrice { get; init; }
 26    public decimal? MinProfit { get; init; }
 27    public decimal? MaxProfit { get; init; }
 28    public decimal? MinDiscount { get; init; }
 29    public decimal? MaxDiscount { get; init; }
 30    public bool IsVerified { get; init; }
 31    public int? ClientStatusId { get; init; }
 32    public int? WarehouseId { get; init; }
 33    public string? WarehouseName { get; init; }
 34    public string? DeliveryCity { get; init; }
 35}
 36
 37/// <summary>
 38/// Группировка заказов по ProductKey в метрики вкладки «Продукты».
 39/// Чистые функции без доступа к БД. Прибыль с аномалиями (&lt; -500 000) нормализуется
 40/// за один проход по группе (бинарный поиск соседей), а не пересортировкой группы на каждый заказ.
 41/// </summary>
 42public static class ProductGroupCalculator
 43{
 44    /// <summary>Порог средней прибыли заказа, ниже которого значение считается аномальным.</summary>
 45    public const double AnomalousProfitThreshold = -500000;
 46
 1108847    public static bool HasProfit(AnalyticsOrderRow o) => o.MinProfit.HasValue || o.MaxProfit.HasValue;
 48
 942449    public static double AverageProfit(AnalyticsOrderRow o) => ((double)(o.MinProfit ?? 0) + (double)(o.MaxProfit ?? 0))
 50
 981151    public static bool IsAnomalousProfit(AnalyticsOrderRow o) => HasProfit(o) && AverageProfit(o) < AnomalousProfitThres
 52
 53    /// <summary>
 54    /// Нормализатор прибыли группы заказов: аномальное значение заменяется средним между соседними
 55    /// (по ProcessingDate) нормальными заказами группы; нет соседей — значением единственного соседа;
 56    /// нет нормальных заказов — 0.
 57    /// </summary>
 58    public sealed class ProfitNormalizer
 59    {
 60        private readonly DateTime[] _dates;
 61        private readonly double[] _profits;
 62        private readonly double _averageOfNormals;
 63
 11364        public ProfitNormalizer(IReadOnlyCollection<AnalyticsOrderRow> group)
 65        {
 11366            var normal = group
 41267                .Where(o => o.ProcessingDate.HasValue && !IsAnomalousProfit(o))
 36068                .OrderBy(o => o.ProcessingDate!.Value)
 11369                .ToArray();
 47370            _dates = normal.Select(o => o.ProcessingDate!.Value).ToArray();
 11371            _profits = normal.Select(AverageProfit).ToArray();
 11372            _averageOfNormals = _profits.Length > 0 ? _profits.Average() : 0;
 11373        }
 74
 75        public decimal Normalize(AnalyticsOrderRow order)
 76        {
 77577            if (!HasProfit(order)) return 0;
 40178            var avg = AverageProfit(order);
 74879            if (avg >= AnomalousProfitThreshold) return (decimal)avg;
 5580            if (_dates.Length == 0) return 0;
 5481            if (!order.ProcessingDate.HasValue) return (decimal)_averageOfNormals;
 82
 5283            var date = order.ProcessingDate.Value;
 5284            var next = LowerBoundAfter(date);
 5285            var prev = FirstNotBefore(date) - 1;
 5286            var hasPrev = prev >= 0;
 5287            var hasNext = next < _dates.Length;
 88
 9889            if (hasPrev && hasNext) return (decimal)((_profits[prev] + _profits[next]) / 2.0);
 1090            if (hasPrev) return (decimal)_profits[prev];
 491            if (hasNext) return (decimal)_profits[next];
 092            return (decimal)_averageOfNormals;
 93        }
 94
 95        /// <summary>Индекс первой даты, строго большей <paramref name="date"/>.</summary>
 96        private int LowerBoundAfter(DateTime date)
 97        {
 10498            int lo = 0, hi = _dates.Length;
 39899            while (lo < hi)
 100            {
 346101                var mid = (lo + hi) >>> 1;
 692102                if (_dates[mid] <= date) lo = mid + 1; else hi = mid;
 103            }
 104
 52105            return lo;
 106        }
 107
 108        /// <summary>Индекс первой даты, не меньшей <paramref name="date"/> (слева от неё — строго меньшие).</summary>
 109        private int FirstNotBefore(DateTime date)
 110        {
 104111            int lo = 0, hi = _dates.Length;
 400112            while (lo < hi)
 113            {
 348114                var mid = (lo + hi) >>> 1;
 696115                if (_dates[mid] < date) lo = mid + 1; else hi = mid;
 116            }
 117
 52118            return lo;
 119        }
 120    }
 121
 122    public static List<ProductGroupDto> Build(IEnumerable<AnalyticsOrderRow> rows)
 123    {
 7124        var result = new List<ProductGroupDto>();
 1159125        foreach (var g in rows.Where(r => !string.IsNullOrEmpty(r.ProductKey)).GroupBy(r => r.ProductKey!))
 126        {
 42127            var list = g.ToList();
 572128            var first = list.MinBy(o => o.Id)!;
 129
 42130            var topClients = list
 530131                .Where(o => !string.IsNullOrEmpty(o.ClientName))
 49132                .GroupBy(o => o.ClientName!)
 8133                .OrderByDescending(cg => cg.Count())
 8134                .ThenBy(cg => cg.Key, StringComparer.Ordinal)
 42135                .Take(3)
 8136                .Select(cg => cg.Key)
 42137                .ToList();
 138
 42139            var totalOrders = list.Count;
 572140            var totalQuantity = list.Sum(o => o.Quantity ?? 0);
 572141            var totalAmount = list.Sum(o => o.ShipmentAmount ?? 0);
 142
 42143            decimal avgProfit = 0;
 42144            var profitOrders = list.Where(HasProfit).ToList();
 42145            if (profitOrders.Count > 0)
 146            {
 6147                var normalizer = new ProfitNormalizer(list);
 54148                avgProfit = (decimal)(profitOrders.Sum(o => (double)normalizer.Normalize(o)) / profitOrders.Count);
 149            }
 150
 42151            decimal avgDiscount = 0;
 572152            var discountOrders = list.Where(o => o.MinDiscount.HasValue || o.MaxDiscount.HasValue).ToList();
 42153            if (discountOrders.Count > 0)
 154            {
 0155                var sum = discountOrders.Sum(o => ((double)(o.MinDiscount ?? 0) + (double)(o.MaxDiscount ?? 0)) / 2.0);
 0156                avgDiscount = (decimal)(sum / discountOrders.Count);
 157            }
 158
 574159            var costs = list.Where(o => o.CostPrice.HasValue).Select(o => o.CostPrice!.Value).ToList();
 572160            var verified = list.Count(o => o.IsVerified);
 161
 42162            result.Add(new ProductGroupDto
 42163            {
 42164                ProductKey = g.Key,
 42165                Article = first.Article ?? "",
 42166                ProductName = first.ProductName ?? "Не указано",
 42167                ManufacturerName = first.ManufacturerName,
 42168                EtProducerName = first.EtProducerName,
 42169                TotalQuantity = totalQuantity,
 530170                TotalTrueQuantity = list.Sum(o => o.Quantity.HasValue
 530171                    ? OzonDomains.Models.Order.ApplyProductKeyMultiplier(o.Quantity.Value, o.ProductKey)
 530172                    : 0),
 42173                TotalOrders = totalOrders,
 42174                TotalShipmentAmount = totalAmount,
 42175                AverageCheck = totalOrders > 0 ? totalAmount / totalOrders : 0,
 530176                MinPrice = list.Min(o => o.Price ?? 0),
 530177                MaxPrice = list.Max(o => o.Price ?? 0),
 42178                AvgPrice = totalQuantity > 0 ? totalAmount / totalQuantity : 0,
 42179                MinCostPrice = costs.Count > 0 ? costs.Min() : null,
 42180                MaxCostPrice = costs.Count > 0 ? costs.Max() : null,
 42181                AvgCostPrice = costs.Count > 0 ? costs.Average() : null,
 42182                VerifiedOrdersCount = verified,
 42183                VerifiedOrdersPercent = totalOrders > 0 ? (decimal)verified / totalOrders * 100 : 0,
 42184                TopClients = topClients,
 42185                AvgProfit = avgProfit,
 42186                AvgDiscount = avgDiscount
 42187            });
 188        }
 189
 7190        return result;
 191    }
 192}
 193
 194/// <summary>Единая проекция заказа в <see cref="AnalyticsOrderRow"/> без Include (EF транслирует в SELECT нужных колоно
 195public static class AnalyticsRowProjection
 196{
 197    public static IQueryable<AnalyticsOrderRow> Project(IQueryable<OzonDomains.Models.Order> query) =>
 198        query.Select(o => new AnalyticsOrderRow
 199        {
 200            Id = o.Id,
 201            ProductKey = o.ProductKey,
 202            Article = o.Article,
 203            ProductName = o.ProductName,
 204            ManufacturerName = o.Manufacturer != null ? o.Manufacturer.Name : null,
 205            EtProducerName = o.EtProducer != null ? o.EtProducer.Name : null,
 206            ClientName = o.OzonClient != null ? o.OzonClient.Name : null,
 207            AppStatusName = o.AppStatus != null ? o.AppStatus.Name : null,
 208            ClientStatusName = o.ClientStatus != null ? o.ClientStatus.Name : null,
 209            AppStatusId = o.AppStatusId,
 210            ClientStatusId = o.ClientStatusId,
 211            WarehouseId = o.ShipmentWarehouseId,
 212            WarehouseName = o.ShipmentWarehouse != null ? o.ShipmentWarehouse.Name : null,
 213            DeliveryCity = o.DeliveryCity,
 214            ShipmentNumber = o.ShipmentNumber,
 215            ProcessingDate = o.ProcessingDate,
 216            ShippingDate = o.ShippingDate,
 217            Quantity = o.Quantity,
 218            Price = o.Price,
 219            ShipmentAmount = o.ShipmentAmount,
 220            CostPrice = o.CostPrice,
 221            MinProfit = o.MinProfit,
 222            MaxProfit = o.MaxProfit,
 223            MinDiscount = o.MinDiscount,
 224            MaxDiscount = o.MaxDiscount,
 225            IsVerified = o.IsVerified,
 226            IsReturnable = o.IsReturnable == true
 227        });
 228
 229    /// <summary>
 230    /// Лёгкие заглушки <see cref="OzonDomains.Models.Order"/> для сервисов прогноза и трендов, которые работают с сущно
 231    /// заполнены только используемые ими поля (дата, количество, артикул, название, склад, город, цена).
 232    /// </summary>
 233    public static List<OzonDomains.Models.Order> ToOrders(IEnumerable<AnalyticsOrderRow> rows) =>
 234        rows.Select(r => new OzonDomains.Models.Order
 235        {
 236            Id = r.Id,
 237            ProductKey = r.ProductKey,
 238            Article = r.Article,
 239            ProductName = r.ProductName,
 240            ProcessingDate = r.ProcessingDate,
 241            Quantity = r.Quantity,
 242            Price = r.Price,
 243            DeliveryCity = r.DeliveryCity,
 244            ShipmentWarehouseId = r.WarehouseId,
 245            ShipmentWarehouse = r.WarehouseName == null ? null : new OzonDomains.Models.Warehouse { Id = r.WarehouseId ?
 246        }).ToList();
 247}