< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.AnalysisServices.ProductAnalysisReportCalculator
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/AnalysisServices/ProductAnalysisReportCalculator.cs
Line coverage
99%
Covered lines: 239
Uncovered lines: 1
Coverable lines: 240
Total lines: 483
Line coverage: 99.5%
Branch coverage
68%
Covered branches: 73
Total branches: 106
Branch coverage: 68.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
MonthLabel(...)100%11100%
ClampDecimal(...)50%44100%
SumProfit(...)100%11100%
Overall(...)100%11100%
Top()100%11100%
IsReturn(...)87.5%88100%
Metrics(...)56.66%3030100%
AverageDiscount(...)100%88100%
Totals(...)100%44100%
Change(...)100%44100%
Point(...)100%11100%
ComparativeRanges(...)100%11100%
Comparative(...)68.75%1616100%
Days()100%11100%
Seasonality(...)100%11100%
OrderStatuses(...)83.33%66100%
TopBy(...)100%11100%
Place(...)50%101090%
WarehouseRegion(...)50%66100%
Summary(...)70%1010100%

File(s)

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

#LineLine coverage
 1namespace OzonOrdersWeb.Areas.Studio2.AnalysisServices;
 2
 3public sealed record LabelValue(string Label, decimal Value);
 4
 5/// <summary>Общая статистика: прибыль, средний чек и число заказов по месяцам и по клиентам (топ-10).</summary>
 6public sealed class OverallStats
 7{
 8    public List<LabelValue> ProfitByMonth { get; init; } = [];
 9    public List<LabelValue> ProfitByClient { get; init; } = [];
 10    public List<LabelValue> AverageCheckByMonth { get; init; } = [];
 11    public List<LabelValue> AverageCheckByClient { get; init; } = [];
 12    public List<LabelValue> OrdersCountByMonth { get; init; } = [];
 13    public List<LabelValue> OrdersCountByClient { get; init; } = [];
 14}
 15
 16public sealed record RoiRow(string ProductKey, string ProductName, int TotalOrders, decimal TotalCost, decimal TotalReve
 17public sealed record ReturnRateRow(string ProductKey, string ProductName, int TotalOrders, int ReturnedOrders, decimal R
 18public sealed record DeliveryTimeRow(string ProductKey, string ProductName, int TotalOrders, decimal AvgDeliveryDays);
 19public sealed record PopularProductRow(string ProductKey, string ProductName, int TotalOrders, decimal TotalAmount, int 
 20public sealed record ReturnStatusRow(string StatusName, int Count, decimal Percentage);
 21public sealed record MetricsSummary(int TotalOrders, int TotalReturned, decimal OverallReturnRate, decimal AvgDeliveryTi
 22
 23public sealed class AdditionalMetrics
 24{
 25    public List<RoiRow> RoiByProduct { get; init; } = [];
 26    public List<ReturnRateRow> ReturnRateByProduct { get; init; } = [];
 27    public List<DeliveryTimeRow> AvgDeliveryTimeByProduct { get; init; } = [];
 28    public List<PopularProductRow> PopularProducts { get; init; } = [];
 29    public MetricsSummary OverallMetrics { get; init; } = new(0, 0, 0, 0, 0);
 30    public List<ReturnStatusRow> ReturnStatusDistribution { get; init; } = [];
 31}
 32
 33public sealed record PeriodTotals(string Period, int TotalOrders, double TotalAmount, int TotalQuantity, double TotalPro
 34    double AverageCheck, double AvgProfit, double AvgDiscount, int UniqueProducts);
 35
 36public sealed record PeriodChanges(double Orders, double Amount, double Quantity, double Profit, double AverageCheck,
 37    double AvgProfit, double AvgDiscount, double UniqueProducts);
 38
 39public sealed record DayPoint(string Date, int Orders, double Amount, double Profit);
 40
 41public sealed class ComparativeResult
 42{
 43    public PeriodTotals CurrentMonth { get; init; } = null!;
 44    public PeriodTotals PreviousMonth { get; init; } = null!;
 45    public PeriodChanges Changes { get; init; } = null!;
 46    public List<DayPoint> CurrentSeries { get; init; } = [];
 47    public List<DayPoint> PreviousSeries { get; init; } = [];
 48    public int MonthsBack { get; init; }
 49    public string? ProductKey { get; init; }
 50}
 51
 52public sealed record SeasonMonth(string Period, int Year, int Month, int OrdersCount, double TotalAmount, int TotalQuant
 53public sealed record SeasonDay(int DayOfWeekNumber, string DayOfWeekName, int OrdersCount, double TotalAmount, int Total
 54public sealed record SeasonHour(int Hour, int OrdersCount, double TotalAmount, int TotalQuantity);
 55
 56public sealed class SeasonalityResult
 57{
 58    public List<SeasonMonth> ByMonth { get; init; } = [];
 59    public List<SeasonDay> ByDayOfWeek { get; init; } = [];
 60    public List<SeasonHour> ByHour { get; init; } = [];
 61    public int TotalOrders { get; init; }
 62    public double TotalAmount { get; init; }
 63}
 64
 65
 66public sealed record StatusStat(int? StatusId, string StatusName, int OrdersCount, double TotalAmount, double AverageChe
 67public sealed record StatusMonthCount(string StatusName, int OrdersCount);
 68public sealed record StatusMonth(string Period, int Year, int Month, List<StatusMonthCount> StatusBreakdown);
 69
 70public sealed class OrderStatusesResult
 71{
 72    public List<StatusStat> ByStatus { get; init; } = [];
 73    public List<StatusMonth> ByMonth { get; init; } = [];
 74    public int TotalOrders { get; init; }
 75    public double TotalAmount { get; init; }
 76    public string StatusType { get; init; } = "app";
 77}
 78
 79/// <summary>Агрегат по складу или региону: итоги, средняя прибыль/наценка, топ-5 продуктов и связанных сущностей.</summ
 80public sealed record PlaceStat(int? Id, string Name, int TotalOrders, int TotalQuantity, decimal TotalShipmentAmount, de
 81    decimal AvgProfit, decimal AvgDiscount, List<string> TopProducts, List<string> TopRelated);
 82
 83public sealed record PlaceMonth(string Period, int Year, int Month, string Name, int OrdersCount, double TotalAmount, do
 84
 85public sealed class WarehouseRegionResult
 86{
 87    public List<PlaceStat> Warehouses { get; init; } = [];
 88    public List<PlaceStat> Regions { get; init; } = [];
 89    public List<PlaceMonth> WarehouseByMonth { get; init; } = [];
 90    /// <summary>Динамика по месяцам — только для топ-10 регионов (для графика).</summary>
 91    public List<PlaceMonth> RegionByMonth { get; init; } = [];
 92    public int TotalRegionsCount { get; init; }
 93}
 94
 95
 96public sealed record SummaryKpis(int TotalOrders, decimal TotalAmount, decimal AverageCheck, decimal TotalProfit, decima
 97    decimal AvgDeliveryDays, int UniqueProducts, int ActiveClients);
 98public sealed record SummaryProduct(string ProductKey, string ProductName, int TotalOrders, decimal TotalAmount, decimal
 99public sealed record SummaryClient(string ClientName, int TotalOrders, decimal TotalAmount, decimal TotalProfit);
 100
 101/// <summary>Стартовая сводка страницы: главные показатели периода, динамика последних месяцев, топы и убыточные продукт
 102public sealed class SummaryResult
 103{
 104    public SummaryKpis Kpis { get; init; } = new(0, 0, 0, 0, 0, 0, 0, 0);
 105    public List<LabelValue> AmountByMonth { get; init; } = [];
 106    public List<LabelValue> ProfitByMonth { get; init; } = [];
 107    public List<SummaryProduct> TopProducts { get; init; } = [];
 108    public List<SummaryProduct> LossProducts { get; init; } = [];
 109    public List<SummaryClient> TopClients { get; init; } = [];
 110}
 111
 112/// <summary>
 113/// Отчёты вкладок «Общая статистика», «Доп. метрики», «Сравнительная», «Сезонность» — чистые функции над
 114/// <see cref="AnalyticsOrderRow"/>. Прибыль нормализуется <see cref="ProductGroupCalculator.ProfitNormalizer"/>
 115/// (один проход по всему набору вместо пересортировки на каждый заказ).
 116/// </summary>
 117public static class ProductAnalysisReportCalculator
 118{
 1119    private static readonly string[] RussianMonths =
 1120        ["", "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Д
 121
 1122    private static readonly string[] RussianDays =
 1123        ["Воскресенье", "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота"];
 124
 1125    private static readonly HashSet<string> ReturnStatusNames = new(StringComparer.Ordinal)
 1126    {
 1127        "Отменен", "Отменён", "cancelled", "CANCELLED", "Возврат покупателя", "RETURNED",
 1128        "PARTIALLY_RETURNED", "Возвращен полностью", "Возвращен частично"
 1129    };
 130
 81131    public static string MonthLabel(int year, int month) => $"{RussianMonths[month]} {year}";
 132
 133    private static decimal ClampDecimal(double value) =>
 50134        value > (double)decimal.MaxValue ? decimal.MaxValue : value < (double)decimal.MinValue ? decimal.MinValue : (dec
 135
 136    private static double SumProfit(IEnumerable<AnalyticsOrderRow> rows, ProductGroupCalculator.ProfitNormalizer normali
 490137        rows.Sum(o => (double)normalizer.Normalize(o));
 138
 139    // ---------- Общая статистика ----------
 140
 141    public static OverallStats Overall(IReadOnlyCollection<AnalyticsOrderRow> orders)
 142    {
 2143        var normalizer = new ProductGroupCalculator.ProfitNormalizer(orders);
 144
 2145        var months = orders
 18146            .Where(o => o.ProcessingDate.HasValue)
 18147            .GroupBy(o => (o.ProcessingDate!.Value.Year, o.ProcessingDate!.Value.Month))
 6148            .OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month)
 2149            .ToList();
 150
 2151        var clients = orders
 18152            .Where(o => !string.IsNullOrEmpty(o.ClientName))
 18153            .GroupBy(o => o.ClientName!)
 17154            .Select(g => (Name: g.Key, Rows: g.ToList()))
 2155            .ToList();
 156
 57157        List<LabelValue> Top(IEnumerable<LabelValue> items) => items.OrderByDescending(x => x.Value).Take(10).ToList();
 158
 2159        return new OverallStats
 2160        {
 3161            ProfitByMonth = months.Select(g => new LabelValue(MonthLabel(g.Key.Year, g.Key.Month), ClampDecimal(SumProfi
 21162            AverageCheckByMonth = months.Select(g => new LabelValue(MonthLabel(g.Key.Year, g.Key.Month), g.Sum(o => o.Sh
 3163            OrdersCountByMonth = months.Select(g => new LabelValue(MonthLabel(g.Key.Year, g.Key.Month), g.Count())).ToLi
 17164            ProfitByClient = Top(clients.Select(c => new LabelValue(c.Name, ClampDecimal(SumProfit(c.Rows, normalizer)))
 35165            AverageCheckByClient = Top(clients.Select(c => new LabelValue(c.Name, c.Rows.Sum(o => o.ShipmentAmount ?? 0)
 17166            OrdersCountByClient = Top(clients.Select(c => new LabelValue(c.Name, c.Rows.Count)))
 2167        };
 168    }
 169
 170    // ---------- Дополнительные метрики ----------
 171
 172    private static bool IsReturn(AnalyticsOrderRow o) =>
 61173        (o.AppStatusName != null && ReturnStatusNames.Contains(o.AppStatusName)) ||
 61174        (o.ClientStatusName != null && ReturnStatusNames.Contains(o.ClientStatusName)) ||
 61175        o.IsReturnable;
 176
 177    public static AdditionalMetrics Metrics(IReadOnlyCollection<AnalyticsOrderRow> orders)
 178    {
 1179        var normalizer = new ProductGroupCalculator.ProfitNormalizer(orders);
 1180        var byProduct = orders
 6181            .Where(o => !string.IsNullOrEmpty(o.ProductKey))
 6182            .GroupBy(o => (o.ProductKey!, ProductName: o.ProductName ?? "Не указано"))
 2183            .Select(g => (g.Key.Item1, g.Key.ProductName, Rows: g.ToList()))
 1184            .ToList();
 185
 186        // ROI считается только по заказам с положительной себестоимостью (как и прежде).
 1187        var roi = orders
 6188            .Where(o => !string.IsNullOrEmpty(o.ProductKey) && o.CostPrice > 0)
 5189            .GroupBy(o => (o.ProductKey!, ProductName: o.ProductName ?? "Не указано"))
 1190            .Select(g =>
 1191            {
 1192                var rows = g.ToList();
 6193                var cost = rows.Sum(o => o.CostPrice ?? 0);
 1194                var profit = (decimal)SumProfit(rows, normalizer);
 6195                return new RoiRow(g.Key.Item1, g.Key.ProductName, rows.Count, cost, rows.Sum(o => o.ShipmentAmount ?? 0)
 1196            })
 2197            .OrderByDescending(x => x.Roi).Take(20).ToList();
 198
 1199        var returnRate = byProduct
 1200            .Select(p =>
 1201            {
 2202                var returned = p.Rows.Count(IsReturn);
 2203                return new ReturnRateRow(p.Item1, p.ProductName, p.Rows.Count, returned, p.Rows.Count > 0 ? (decimal)ret
 1204            })
 2205            .Where(x => x.TotalOrders >= 5)
 2206            .OrderByDescending(x => x.ReturnRate).Take(20).ToList();
 207
 208        // Время выполнения — только по заказам с обеими датами; нужно минимум 3 таких заказа.
 1209        var delivery = orders
 6210            .Where(o => !string.IsNullOrEmpty(o.ProductKey) && o.ProcessingDate.HasValue && o.ShippingDate.HasValue)
 5211            .GroupBy(o => (o.ProductKey!, ProductName: o.ProductName ?? "Не указано"))
 1212            .Select(g => new DeliveryTimeRow(g.Key.Item1, g.Key.ProductName, g.Count(),
 6213                (decimal)g.Average(o => (o.ShippingDate!.Value - o.ProcessingDate!.Value).TotalDays)))
 1214            .Where(x => x.TotalOrders >= 3)
 2215            .OrderByDescending(x => x.AvgDeliveryDays).Take(20).ToList();
 216
 1217        var popular = byProduct
 1218            .Select(p =>
 1219            {
 8220                var amount = p.Rows.Sum(o => o.ShipmentAmount ?? 0);
 8221                return new PopularProductRow(p.Item1, p.ProductName, p.Rows.Count, amount, p.Rows.Sum(o => o.Quantity ??
 1222            })
 5223            .OrderByDescending(x => x.TotalOrders).ThenByDescending(x => x.TotalAmount).Take(20).ToList();
 224
 1225        var total = orders.Count;
 1226        var returnedAll = orders.Count(IsReturn);
 7227        var withDates = orders.Where(o => o.ProcessingDate.HasValue && o.ShippingDate.HasValue).ToList();
 1228        var statusDistribution = orders
 1229            .Where(IsReturn)
 3230            .GroupBy(o => o.AppStatusName ?? o.ClientStatusName ?? "Не указан")
 2231            .Select(g => new ReturnStatusRow(g.Key, g.Count(), total > 0 ? (decimal)g.Count() / total * 100 : 0))
 3232            .OrderByDescending(x => x.Count).ToList();
 233
 1234        return new AdditionalMetrics
 1235        {
 1236            RoiByProduct = roi,
 1237            ReturnRateByProduct = returnRate,
 1238            AvgDeliveryTimeByProduct = delivery,
 1239            PopularProducts = popular,
 1240            OverallMetrics = new MetricsSummary(total, returnedAll, total > 0 ? (decimal)returnedAll / total * 100 : 0,
 5241                withDates.Count > 0 ? (decimal)withDates.Average(o => (o.ShippingDate!.Value - o.ProcessingDate!.Value).
 1242                withDates.Count),
 1243            ReturnStatusDistribution = statusDistribution
 1244        };
 245    }
 246
 247    // ---------- Сравнительная аналитика ----------
 248
 249    private static double AverageDiscount(IReadOnlyCollection<AnalyticsOrderRow> rows)
 250    {
 4251        double total = 0;
 4252        var count = 0;
 20253        foreach (var o in rows)
 254        {
 6255            var min = (double)(o.MinDiscount ?? 0);
 6256            var max = (double)(o.MaxDiscount ?? 0);
 10257            if (min > 0 || max > 0) { total += (min + max) / 2.0; count++; }
 258        }
 259
 4260        return count > 0 ? total / count : 0;
 261    }
 262
 263    private static PeriodTotals Totals(string period, IReadOnlyCollection<AnalyticsOrderRow> rows)
 264    {
 4265        var profit = SumProfit(rows, new ProductGroupCalculator.ProfitNormalizer(rows));
 10266        var amount = (double)rows.Sum(o => o.ShipmentAmount ?? 0);
 4267        var count = rows.Count;
 10268        return new PeriodTotals(period, count, amount, rows.Sum(o => o.Quantity ?? 0), profit,
 4269            count > 0 ? amount / count : 0, count > 0 ? profit / count : 0, AverageDiscount(rows),
 16270            rows.Where(o => !string.IsNullOrEmpty(o.ProductKey)).Select(o => o.ProductKey).Distinct().Count());
 271    }
 272
 273    private static double Change(double current, double previous) =>
 16274        previous == 0 ? (current > 0 ? 100 : 0) : (current - previous) / previous * 100;
 275
 276    private static DayPoint Point(string label, IReadOnlyCollection<AnalyticsOrderRow> rows) =>
 100277        new(label, rows.Count, (double)rows.Sum(o => o.ShipmentAmount ?? 0), SumProfit(rows, new ProductGroupCalculator.
 278
 279    /// <summary>Границы сравнения: текущий месяц и <paramref name="monthsBack"/> месяцев перед ним.</summary>
 280    public static (DateTime CurrentStart, DateTime CurrentEnd, DateTime PreviousStart, DateTime PreviousEnd) Comparative
 281    {
 2282        var currentStart = new DateTime(now.Year, now.Month, 1);
 2283        var currentEnd = currentStart.AddMonths(1).AddDays(-1).AddHours(23).AddMinutes(59).AddSeconds(59);
 2284        var previousStart = currentStart.AddMonths(-monthsBack);
 2285        var previousEnd = currentStart.AddDays(-1).AddHours(23).AddMinutes(59).AddSeconds(59);
 2286        return (currentStart, currentEnd, previousStart, previousEnd);
 287    }
 288
 289    public static ComparativeResult Comparative(IReadOnlyCollection<AnalyticsOrderRow> rows, DateTime now, int monthsBac
 290    {
 2291        var (cs, ce, ps, pe) = ComparativeRanges(now, monthsBack);
 8292        var current = rows.Where(o => o.ProcessingDate >= cs && o.ProcessingDate <= ce).ToList();
 8293        var previous = rows.Where(o => o.ProcessingDate >= ps && o.ProcessingDate <= pe).ToList();
 2294        var prevMonth = now.AddMonths(-1);
 295
 2296        var cur = Totals(MonthLabel(now.Year, now.Month), current);
 2297        var prev = Totals(monthsBack > 1 ? $"Последние {monthsBack} месяцев" : MonthLabel(prevMonth.Year, prevMonth.Mont
 298
 299        List<DayPoint> Days(int year, int month, List<AnalyticsOrderRow> source)
 300        {
 9301            var byDay = source.Where(o => o.ProcessingDate.HasValue).ToLookup(o => o.ProcessingDate!.Value.Date);
 3302            return Enumerable.Range(1, DateTime.DaysInMonth(year, month))
 184303                .Select(day => { var d = new DateTime(year, month, day); return Point(d.ToString("dd.MM"), byDay[d].ToLi
 3304                .ToList();
 305        }
 306
 2307        var previousSeries = monthsBack > 1
 3308            ? previous.Where(o => o.ProcessingDate.HasValue)
 3309                .GroupBy(o => (o.ProcessingDate!.Value.Year, o.ProcessingDate!.Value.Month))
 4310                .OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month)
 2311                .Select(g => Point(MonthLabel(g.Key.Year, g.Key.Month), g.ToList())).ToList()
 2312            : Days(prevMonth.Year, prevMonth.Month, previous);
 313
 2314        return new ComparativeResult
 2315        {
 2316            CurrentMonth = cur,
 2317            PreviousMonth = prev,
 2318            Changes = new PeriodChanges(Change(cur.TotalOrders, prev.TotalOrders), Change(cur.TotalAmount, prev.TotalAmo
 2319                Change(cur.TotalQuantity, prev.TotalQuantity), Change(cur.TotalProfit, prev.TotalProfit),
 2320                Change(cur.AverageCheck, prev.AverageCheck), Change(cur.AvgProfit, prev.AvgProfit),
 2321                Change(cur.AvgDiscount, prev.AvgDiscount), Change(cur.UniqueProducts, prev.UniqueProducts)),
 2322            CurrentSeries = Days(now.Year, now.Month, current),
 2323            PreviousSeries = previousSeries,
 2324            MonthsBack = monthsBack,
 2325            ProductKey = productKey
 2326        };
 327    }
 328
 329    // ---------- Сезонность ----------
 330
 331    public static SeasonalityResult Seasonality(IReadOnlyCollection<AnalyticsOrderRow> rows)
 332    {
 5333        var dated = rows.Where(o => o.ProcessingDate.HasValue).ToList();
 1334        return new SeasonalityResult
 1335        {
 3336            ByMonth = dated.GroupBy(o => (o.ProcessingDate!.Value.Year, o.ProcessingDate!.Value.Month))
 4337                .OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month)
 2338                .Select(g => new SeasonMonth(MonthLabel(g.Key.Year, g.Key.Month), g.Key.Year, g.Key.Month, g.Count(),
 8339                    (double)g.Sum(o => o.ShipmentAmount ?? 0), g.Sum(o => o.Quantity ?? 0))).ToList(),
 3340            ByDayOfWeek = dated.GroupBy(o => (int)o.ProcessingDate!.Value.DayOfWeek)
 2341                .OrderBy(g => g.Key)
 8342                .Select(g => new SeasonDay(g.Key, RussianDays[g.Key], g.Count(), (double)g.Sum(o => o.ShipmentAmount ?? 
 3343            ByHour = dated.GroupBy(o => o.ProcessingDate!.Value.Hour)
 2344                .OrderBy(g => g.Key)
 8345                .Select(g => new SeasonHour(g.Key, g.Count(), (double)g.Sum(o => o.ShipmentAmount ?? 0), g.Sum(o => o.Qu
 1346            TotalOrders = dated.Count,
 3347            TotalAmount = (double)dated.Sum(o => o.ShipmentAmount ?? 0)
 1348        };
 349    }
 350
 351    // ---------- Статусы заказов ----------
 352
 353    public static OrderStatusesResult OrderStatuses(IReadOnlyCollection<AnalyticsOrderRow> rows, string statusType)
 354    {
 2355        var app = !string.Equals(statusType, "client", StringComparison.OrdinalIgnoreCase);
 9356        var dated = rows.Where(o => o.ProcessingDate.HasValue).ToList();
 357        (int? Id, string Name)? Key(AnalyticsOrderRow o)
 358        {
 359            var id = app ? o.AppStatusId : o.ClientStatusId;
 360            if (!id.HasValue) return null;
 361            return (id, (app ? o.AppStatusName : o.ClientStatusName) ?? "Не указан");
 362        }
 363
 14364        var withStatus = dated.Select(o => (Row: o, Key: Key(o))).Where(x => x.Key.HasValue).ToList();
 365
 2366        var byStatus = withStatus
 4367            .GroupBy(x => x.Key!.Value)
 2368            .Select(g =>
 2369            {
 7370                var amount = g.Sum(x => x.Row.ShipmentAmount ?? 0);
 3371                return new StatusStat(g.Key.Id, g.Key.Name, g.Count(), (double)amount, g.Count() > 0 ? (double)(amount /
 2372            })
 5373            .OrderByDescending(x => x.OrdersCount).ToList();
 374
 2375        var byMonth = dated
 6376            .GroupBy(o => (o.ProcessingDate!.Value.Year, o.ProcessingDate!.Value.Month))
 6377            .OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month)
 3378            .Select(g => new StatusMonth(MonthLabel(g.Key.Year, g.Key.Month), g.Key.Year, g.Key.Month,
 16379                g.Select(o => Key(o)).Where(k => k.HasValue).GroupBy(k => k!.Value)
 7380                    .Select(sg => new StatusMonthCount(sg.Key.Name, sg.Count())).ToList()))
 2381            .ToList();
 382
 2383        return new OrderStatusesResult
 2384        {
 2385            ByStatus = byStatus,
 2386            ByMonth = byMonth,
 2387            TotalOrders = dated.Count,
 6388            TotalAmount = (double)dated.Sum(o => o.ShipmentAmount ?? 0),
 2389            StatusType = app ? "app" : "client"
 2390        };
 391    }
 392
 393    // ---------- Склады и регионы ----------
 394
 395    private static List<string> TopBy(IEnumerable<AnalyticsOrderRow> rows, Func<AnalyticsOrderRow, string?> key, Func<IG
 652396        rows.Where(o => !string.IsNullOrEmpty(key(o))).GroupBy(o => key(o)!)
 118397            .OrderByDescending(g => g.Count()).ThenBy(g => g.Key, StringComparer.Ordinal).Take(5).Select(label).ToList()
 398
 399    private static PlaceStat Place(int? id, string name, List<AnalyticsOrderRow> rows, ProductGroupCalculator.ProfitNorm
 400        Func<AnalyticsOrderRow, string?> relatedKey)
 401    {
 170402        var amount = rows.Sum(o => o.ShipmentAmount ?? 0);
 14403        var profitRows = rows.Where(ProductGroupCalculator.HasProfit).ToList();
 14404        var avgProfit = profitRows.Count > 0 ? (decimal)(SumProfit(profitRows, normalizer) / profitRows.Count) : 0;
 170405        var discountRows = rows.Where(o => o.MinDiscount.HasValue || o.MaxDiscount.HasValue).ToList();
 14406        var avgDiscount = discountRows.Count > 0
 0407            ? (decimal)(discountRows.Sum(o => ((double)(o.MinDiscount ?? 0) + (double)(o.MaxDiscount ?? 0)) / 2.0) / dis
 14408            : 0;
 170409        return new PlaceStat(id, name, rows.Count, rows.Sum(o => o.Quantity ?? 0), amount, rows.Count > 0 ? amount / row
 339410            TopBy(rows, o => o.ProductKey, g => g.First().ProductName ?? g.Key),
 32411            TopBy(rows, relatedKey, g => g.Key));
 412    }
 413
 414    public static WarehouseRegionResult WarehouseRegion(IReadOnlyCollection<AnalyticsOrderRow> orders)
 415    {
 1416        var normalizer = new ProductGroupCalculator.ProfitNormalizer(orders);
 417
 79418        var warehouses = orders.Where(o => o.WarehouseId.HasValue)
 78419            .GroupBy(o => (o.WarehouseId, Name: o.WarehouseName ?? "Не указан"))
 158420            .Select(g => Place(g.Key.WarehouseId, g.Key.Name, g.ToList(), normalizer, o => o.ClientName))
 3421            .OrderByDescending(w => w.TotalOrders).ToList();
 422
 79423        var regions = orders.Where(o => !string.IsNullOrEmpty(o.DeliveryCity))
 78424            .GroupBy(o => o.DeliveryCity!)
 168425            .Select(g => Place(null, g.Key, g.ToList(), normalizer, o => o.WarehouseName))
 13426            .OrderByDescending(r => r.TotalOrders).ToList();
 427
 428        List<PlaceMonth> ByMonth(IEnumerable<AnalyticsOrderRow> rows, Func<AnalyticsOrderRow, string> name) =>
 429            rows.Where(o => o.ProcessingDate.HasValue)
 430                .GroupBy(o => (o.ProcessingDate!.Value.Year, o.ProcessingDate!.Value.Month, Name: name(o)))
 431                .OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month).ThenBy(g => g.Key.Name, StringComparer.Ordinal)
 432                .Select(g => new PlaceMonth(MonthLabel(g.Key.Year, g.Key.Month), g.Key.Year, g.Key.Month, g.Key.Name, g.
 433                    (double)g.Sum(o => o.ShipmentAmount ?? 0), SumProfit(g, normalizer))).ToList();
 434
 11435        var topRegions = regions.Take(10).Select(r => r.Name).ToHashSet(StringComparer.Ordinal);
 436
 1437        return new WarehouseRegionResult
 1438        {
 1439            Warehouses = warehouses,
 1440            Regions = regions,
 156441            WarehouseByMonth = ByMonth(orders.Where(o => o.WarehouseId.HasValue), o => o.WarehouseName ?? "Не указан"),
 153442            RegionByMonth = ByMonth(orders.Where(o => !string.IsNullOrEmpty(o.DeliveryCity) && topRegions.Contains(o.Del
 1443            TotalRegionsCount = regions.Count
 1444        };
 445    }
 446
 447    // ---------- Стартовая сводка ----------
 448
 449    public const int SummaryMonths = 24;
 450
 451    public static SummaryResult Summary(IReadOnlyCollection<AnalyticsOrderRow> orders)
 452    {
 2453        var normalizer = new ProductGroupCalculator.ProfitNormalizer(orders);
 2454        var total = orders.Count;
 45455        var amount = orders.Sum(o => o.ShipmentAmount ?? 0);
 2456        var profit = ClampDecimal(SumProfit(orders, normalizer));
 2457        var returned = orders.Count(IsReturn);
 45458        var withDates = orders.Where(o => o.ProcessingDate.HasValue && o.ShippingDate.HasValue).ToList();
 459
 45460        var months = orders.Where(o => o.ProcessingDate.HasValue)
 43461            .GroupBy(o => (o.ProcessingDate!.Value.Year, o.ProcessingDate!.Value.Month))
 62462            .OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month)
 2463            .TakeLast(SummaryMonths).ToList();
 464
 2465        var products = ProductGroupCalculator.Build(orders)
 6466            .Select(g => new SummaryProduct(g.ProductKey, g.ProductName, g.TotalOrders, g.TotalShipmentAmount, g.AvgProf
 467
 88468        var clients = orders.Where(o => !string.IsNullOrEmpty(o.ClientName)).GroupBy(o => o.ClientName!)
 48469            .Select(g => new SummaryClient(g.Key, g.Count(), g.Sum(o => o.ShipmentAmount ?? 0), ClampDecimal(SumProfit(g
 470
 2471        return new SummaryResult
 2472        {
 2473            Kpis = new SummaryKpis(total, amount, total > 0 ? amount / total : 0, profit, total > 0 ? (decimal)returned 
 6474                withDates.Count > 0 ? (decimal)withDates.Average(o => (o.ShippingDate!.Value - o.ProcessingDate!.Value).
 2475                products.Count, clients.Count),
 62476            AmountByMonth = months.Select(g => new LabelValue(MonthLabel(g.Key.Year, g.Key.Month), g.Sum(o => o.Shipment
 25477            ProfitByMonth = months.Select(g => new LabelValue(MonthLabel(g.Key.Year, g.Key.Month), ClampDecimal(SumProfi
 8478            TopProducts = products.OrderByDescending(p => p.TotalAmount).ThenBy(p => p.ProductKey, StringComparer.Ordina
 6479            LossProducts = products.Where(p => p.TotalOrders >= 5 && p.AvgProfit < 0).OrderBy(p => p.AvgProfit).ThenBy(p
 6480            TopClients = clients.OrderByDescending(c => c.TotalAmount).ThenBy(c => c.ClientName, StringComparer.Ordinal)
 2481        };
 482    }
 483}

Methods/Properties

.cctor()
MonthLabel(System.Int32,System.Int32)
ClampDecimal(System.Double)
SumProfit(System.Collections.Generic.IEnumerable`1<OzonOrdersWeb.Areas.Studio2.AnalysisServices.AnalyticsOrderRow>,OzonOrdersWeb.Areas.Studio2.AnalysisServices.ProductGroupCalculator/ProfitNormalizer)
Overall(System.Collections.Generic.IReadOnlyCollection`1<OzonOrdersWeb.Areas.Studio2.AnalysisServices.AnalyticsOrderRow>)
Top()
IsReturn(OzonOrdersWeb.Areas.Studio2.AnalysisServices.AnalyticsOrderRow)
Metrics(System.Collections.Generic.IReadOnlyCollection`1<OzonOrdersWeb.Areas.Studio2.AnalysisServices.AnalyticsOrderRow>)
AverageDiscount(System.Collections.Generic.IReadOnlyCollection`1<OzonOrdersWeb.Areas.Studio2.AnalysisServices.AnalyticsOrderRow>)
Totals(System.String,System.Collections.Generic.IReadOnlyCollection`1<OzonOrdersWeb.Areas.Studio2.AnalysisServices.AnalyticsOrderRow>)
Change(System.Double,System.Double)
Point(System.String,System.Collections.Generic.IReadOnlyCollection`1<OzonOrdersWeb.Areas.Studio2.AnalysisServices.AnalyticsOrderRow>)
ComparativeRanges(System.DateTime,System.Int32)
Comparative(System.Collections.Generic.IReadOnlyCollection`1<OzonOrdersWeb.Areas.Studio2.AnalysisServices.AnalyticsOrderRow>,System.DateTime,System.Int32,System.String)
Days()
Seasonality(System.Collections.Generic.IReadOnlyCollection`1<OzonOrdersWeb.Areas.Studio2.AnalysisServices.AnalyticsOrderRow>)
OrderStatuses(System.Collections.Generic.IReadOnlyCollection`1<OzonOrdersWeb.Areas.Studio2.AnalysisServices.AnalyticsOrderRow>,System.String)
TopBy(System.Collections.Generic.IEnumerable`1<OzonOrdersWeb.Areas.Studio2.AnalysisServices.AnalyticsOrderRow>,System.Func`2<OzonOrdersWeb.Areas.Studio2.AnalysisServices.AnalyticsOrderRow,System.String>,System.Func`2<System.Linq.IGrouping`2<System.String,OzonOrdersWeb.Areas.Studio2.AnalysisServices.AnalyticsOrderRow>,System.String>)
Place(System.Nullable`1<System.Int32>,System.String,System.Collections.Generic.List`1<OzonOrdersWeb.Areas.Studio2.AnalysisServices.AnalyticsOrderRow>,OzonOrdersWeb.Areas.Studio2.AnalysisServices.ProductGroupCalculator/ProfitNormalizer,System.Func`2<OzonOrdersWeb.Areas.Studio2.AnalysisServices.AnalyticsOrderRow,System.String>)
WarehouseRegion(System.Collections.Generic.IReadOnlyCollection`1<OzonOrdersWeb.Areas.Studio2.AnalysisServices.AnalyticsOrderRow>)
Summary(System.Collections.Generic.IReadOnlyCollection`1<OzonOrdersWeb.Areas.Studio2.AnalysisServices.AnalyticsOrderRow>)