< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.AnalysisServices.AnalyticsRowProjection
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/AnalysisServices/ProductGroupCalculator.cs
Line coverage
30%
Covered lines: 13
Uncovered lines: 30
Coverable lines: 43
Total lines: 247
Line coverage: 30.2%
Branch coverage
50%
Covered branches: 1
Total branches: 2
Branch coverage: 50%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Project(...)100%210%
ToOrders(...)50%22100%

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
 47    public static bool HasProfit(AnalyticsOrderRow o) => o.MinProfit.HasValue || o.MaxProfit.HasValue;
 48
 49    public static double AverageProfit(AnalyticsOrderRow o) => ((double)(o.MinProfit ?? 0) + (double)(o.MaxProfit ?? 0))
 50
 51    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
 64        public ProfitNormalizer(IReadOnlyCollection<AnalyticsOrderRow> group)
 65        {
 66            var normal = group
 67                .Where(o => o.ProcessingDate.HasValue && !IsAnomalousProfit(o))
 68                .OrderBy(o => o.ProcessingDate!.Value)
 69                .ToArray();
 70            _dates = normal.Select(o => o.ProcessingDate!.Value).ToArray();
 71            _profits = normal.Select(AverageProfit).ToArray();
 72            _averageOfNormals = _profits.Length > 0 ? _profits.Average() : 0;
 73        }
 74
 75        public decimal Normalize(AnalyticsOrderRow order)
 76        {
 77            if (!HasProfit(order)) return 0;
 78            var avg = AverageProfit(order);
 79            if (avg >= AnomalousProfitThreshold) return (decimal)avg;
 80            if (_dates.Length == 0) return 0;
 81            if (!order.ProcessingDate.HasValue) return (decimal)_averageOfNormals;
 82
 83            var date = order.ProcessingDate.Value;
 84            var next = LowerBoundAfter(date);
 85            var prev = FirstNotBefore(date) - 1;
 86            var hasPrev = prev >= 0;
 87            var hasNext = next < _dates.Length;
 88
 89            if (hasPrev && hasNext) return (decimal)((_profits[prev] + _profits[next]) / 2.0);
 90            if (hasPrev) return (decimal)_profits[prev];
 91            if (hasNext) return (decimal)_profits[next];
 92            return (decimal)_averageOfNormals;
 93        }
 94
 95        /// <summary>Индекс первой даты, строго большей <paramref name="date"/>.</summary>
 96        private int LowerBoundAfter(DateTime date)
 97        {
 98            int lo = 0, hi = _dates.Length;
 99            while (lo < hi)
 100            {
 101                var mid = (lo + hi) >>> 1;
 102                if (_dates[mid] <= date) lo = mid + 1; else hi = mid;
 103            }
 104
 105            return lo;
 106        }
 107
 108        /// <summary>Индекс первой даты, не меньшей <paramref name="date"/> (слева от неё — строго меньшие).</summary>
 109        private int FirstNotBefore(DateTime date)
 110        {
 111            int lo = 0, hi = _dates.Length;
 112            while (lo < hi)
 113            {
 114                var mid = (lo + hi) >>> 1;
 115                if (_dates[mid] < date) lo = mid + 1; else hi = mid;
 116            }
 117
 118            return lo;
 119        }
 120    }
 121
 122    public static List<ProductGroupDto> Build(IEnumerable<AnalyticsOrderRow> rows)
 123    {
 124        var result = new List<ProductGroupDto>();
 125        foreach (var g in rows.Where(r => !string.IsNullOrEmpty(r.ProductKey)).GroupBy(r => r.ProductKey!))
 126        {
 127            var list = g.ToList();
 128            var first = list.MinBy(o => o.Id)!;
 129
 130            var topClients = list
 131                .Where(o => !string.IsNullOrEmpty(o.ClientName))
 132                .GroupBy(o => o.ClientName!)
 133                .OrderByDescending(cg => cg.Count())
 134                .ThenBy(cg => cg.Key, StringComparer.Ordinal)
 135                .Take(3)
 136                .Select(cg => cg.Key)
 137                .ToList();
 138
 139            var totalOrders = list.Count;
 140            var totalQuantity = list.Sum(o => o.Quantity ?? 0);
 141            var totalAmount = list.Sum(o => o.ShipmentAmount ?? 0);
 142
 143            decimal avgProfit = 0;
 144            var profitOrders = list.Where(HasProfit).ToList();
 145            if (profitOrders.Count > 0)
 146            {
 147                var normalizer = new ProfitNormalizer(list);
 148                avgProfit = (decimal)(profitOrders.Sum(o => (double)normalizer.Normalize(o)) / profitOrders.Count);
 149            }
 150
 151            decimal avgDiscount = 0;
 152            var discountOrders = list.Where(o => o.MinDiscount.HasValue || o.MaxDiscount.HasValue).ToList();
 153            if (discountOrders.Count > 0)
 154            {
 155                var sum = discountOrders.Sum(o => ((double)(o.MinDiscount ?? 0) + (double)(o.MaxDiscount ?? 0)) / 2.0);
 156                avgDiscount = (decimal)(sum / discountOrders.Count);
 157            }
 158
 159            var costs = list.Where(o => o.CostPrice.HasValue).Select(o => o.CostPrice!.Value).ToList();
 160            var verified = list.Count(o => o.IsVerified);
 161
 162            result.Add(new ProductGroupDto
 163            {
 164                ProductKey = g.Key,
 165                Article = first.Article ?? "",
 166                ProductName = first.ProductName ?? "Не указано",
 167                ManufacturerName = first.ManufacturerName,
 168                EtProducerName = first.EtProducerName,
 169                TotalQuantity = totalQuantity,
 170                TotalTrueQuantity = list.Sum(o => o.Quantity.HasValue
 171                    ? OzonDomains.Models.Order.ApplyProductKeyMultiplier(o.Quantity.Value, o.ProductKey)
 172                    : 0),
 173                TotalOrders = totalOrders,
 174                TotalShipmentAmount = totalAmount,
 175                AverageCheck = totalOrders > 0 ? totalAmount / totalOrders : 0,
 176                MinPrice = list.Min(o => o.Price ?? 0),
 177                MaxPrice = list.Max(o => o.Price ?? 0),
 178                AvgPrice = totalQuantity > 0 ? totalAmount / totalQuantity : 0,
 179                MinCostPrice = costs.Count > 0 ? costs.Min() : null,
 180                MaxCostPrice = costs.Count > 0 ? costs.Max() : null,
 181                AvgCostPrice = costs.Count > 0 ? costs.Average() : null,
 182                VerifiedOrdersCount = verified,
 183                VerifiedOrdersPercent = totalOrders > 0 ? (decimal)verified / totalOrders * 100 : 0,
 184                TopClients = topClients,
 185                AvgProfit = avgProfit,
 186                AvgDiscount = avgDiscount
 187            });
 188        }
 189
 190        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) =>
 0198        query.Select(o => new AnalyticsOrderRow
 0199        {
 0200            Id = o.Id,
 0201            ProductKey = o.ProductKey,
 0202            Article = o.Article,
 0203            ProductName = o.ProductName,
 0204            ManufacturerName = o.Manufacturer != null ? o.Manufacturer.Name : null,
 0205            EtProducerName = o.EtProducer != null ? o.EtProducer.Name : null,
 0206            ClientName = o.OzonClient != null ? o.OzonClient.Name : null,
 0207            AppStatusName = o.AppStatus != null ? o.AppStatus.Name : null,
 0208            ClientStatusName = o.ClientStatus != null ? o.ClientStatus.Name : null,
 0209            AppStatusId = o.AppStatusId,
 0210            ClientStatusId = o.ClientStatusId,
 0211            WarehouseId = o.ShipmentWarehouseId,
 0212            WarehouseName = o.ShipmentWarehouse != null ? o.ShipmentWarehouse.Name : null,
 0213            DeliveryCity = o.DeliveryCity,
 0214            ShipmentNumber = o.ShipmentNumber,
 0215            ProcessingDate = o.ProcessingDate,
 0216            ShippingDate = o.ShippingDate,
 0217            Quantity = o.Quantity,
 0218            Price = o.Price,
 0219            ShipmentAmount = o.ShipmentAmount,
 0220            CostPrice = o.CostPrice,
 0221            MinProfit = o.MinProfit,
 0222            MaxProfit = o.MaxProfit,
 0223            MinDiscount = o.MinDiscount,
 0224            MaxDiscount = o.MaxDiscount,
 0225            IsVerified = o.IsVerified,
 0226            IsReturnable = o.IsReturnable == true
 0227        });
 228
 229    /// <summary>
 230    /// Лёгкие заглушки <see cref="OzonDomains.Models.Order"/> для сервисов прогноза и трендов, которые работают с сущно
 231    /// заполнены только используемые ими поля (дата, количество, артикул, название, склад, город, цена).
 232    /// </summary>
 233    public static List<OzonDomains.Models.Order> ToOrders(IEnumerable<AnalyticsOrderRow> rows) =>
 2234        rows.Select(r => new OzonDomains.Models.Order
 2235        {
 2236            Id = r.Id,
 2237            ProductKey = r.ProductKey,
 2238            Article = r.Article,
 2239            ProductName = r.ProductName,
 2240            ProcessingDate = r.ProcessingDate,
 2241            Quantity = r.Quantity,
 2242            Price = r.Price,
 2243            DeliveryCity = r.DeliveryCity,
 2244            ShipmentWarehouseId = r.WarehouseId,
 2245            ShipmentWarehouse = r.WarehouseName == null ? null : new OzonDomains.Models.Warehouse { Id = r.WarehouseId ?
 2246        }).ToList();
 247}