< Summary

Information
Class: OzonOrdersWeb.Areas.Studio2.AnalysisServices.SeasonHour
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Areas/Studio2/AnalysisServices/ProductAnalysisReportCalculator.cs
Line coverage
100%
Covered lines: 1
Uncovered lines: 0
Coverable lines: 1
Total lines: 483
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
get_Hour()100%11100%

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
 454public 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{
 119    private static readonly string[] RussianMonths =
 120        ["", "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Д
 121
 122    private static readonly string[] RussianDays =
 123        ["Воскресенье", "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота"];
 124
 125    private static readonly HashSet<string> ReturnStatusNames = new(StringComparer.Ordinal)
 126    {
 127        "Отменен", "Отменён", "cancelled", "CANCELLED", "Возврат покупателя", "RETURNED",
 128        "PARTIALLY_RETURNED", "Возвращен полностью", "Возвращен частично"
 129    };
 130
 131    public static string MonthLabel(int year, int month) => $"{RussianMonths[month]} {year}";
 132
 133    private static decimal ClampDecimal(double value) =>
 134        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
 137        rows.Sum(o => (double)normalizer.Normalize(o));
 138
 139    // ---------- Общая статистика ----------
 140
 141    public static OverallStats Overall(IReadOnlyCollection<AnalyticsOrderRow> orders)
 142    {
 143        var normalizer = new ProductGroupCalculator.ProfitNormalizer(orders);
 144
 145        var months = orders
 146            .Where(o => o.ProcessingDate.HasValue)
 147            .GroupBy(o => (o.ProcessingDate!.Value.Year, o.ProcessingDate!.Value.Month))
 148            .OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month)
 149            .ToList();
 150
 151        var clients = orders
 152            .Where(o => !string.IsNullOrEmpty(o.ClientName))
 153            .GroupBy(o => o.ClientName!)
 154            .Select(g => (Name: g.Key, Rows: g.ToList()))
 155            .ToList();
 156
 157        List<LabelValue> Top(IEnumerable<LabelValue> items) => items.OrderByDescending(x => x.Value).Take(10).ToList();
 158
 159        return new OverallStats
 160        {
 161            ProfitByMonth = months.Select(g => new LabelValue(MonthLabel(g.Key.Year, g.Key.Month), ClampDecimal(SumProfi
 162            AverageCheckByMonth = months.Select(g => new LabelValue(MonthLabel(g.Key.Year, g.Key.Month), g.Sum(o => o.Sh
 163            OrdersCountByMonth = months.Select(g => new LabelValue(MonthLabel(g.Key.Year, g.Key.Month), g.Count())).ToLi
 164            ProfitByClient = Top(clients.Select(c => new LabelValue(c.Name, ClampDecimal(SumProfit(c.Rows, normalizer)))
 165            AverageCheckByClient = Top(clients.Select(c => new LabelValue(c.Name, c.Rows.Sum(o => o.ShipmentAmount ?? 0)
 166            OrdersCountByClient = Top(clients.Select(c => new LabelValue(c.Name, c.Rows.Count)))
 167        };
 168    }
 169
 170    // ---------- Дополнительные метрики ----------
 171
 172    private static bool IsReturn(AnalyticsOrderRow o) =>
 173        (o.AppStatusName != null && ReturnStatusNames.Contains(o.AppStatusName)) ||
 174        (o.ClientStatusName != null && ReturnStatusNames.Contains(o.ClientStatusName)) ||
 175        o.IsReturnable;
 176
 177    public static AdditionalMetrics Metrics(IReadOnlyCollection<AnalyticsOrderRow> orders)
 178    {
 179        var normalizer = new ProductGroupCalculator.ProfitNormalizer(orders);
 180        var byProduct = orders
 181            .Where(o => !string.IsNullOrEmpty(o.ProductKey))
 182            .GroupBy(o => (o.ProductKey!, ProductName: o.ProductName ?? "Не указано"))
 183            .Select(g => (g.Key.Item1, g.Key.ProductName, Rows: g.ToList()))
 184            .ToList();
 185
 186        // ROI считается только по заказам с положительной себестоимостью (как и прежде).
 187        var roi = orders
 188            .Where(o => !string.IsNullOrEmpty(o.ProductKey) && o.CostPrice > 0)
 189            .GroupBy(o => (o.ProductKey!, ProductName: o.ProductName ?? "Не указано"))
 190            .Select(g =>
 191            {
 192                var rows = g.ToList();
 193                var cost = rows.Sum(o => o.CostPrice ?? 0);
 194                var profit = (decimal)SumProfit(rows, normalizer);
 195                return new RoiRow(g.Key.Item1, g.Key.ProductName, rows.Count, cost, rows.Sum(o => o.ShipmentAmount ?? 0)
 196            })
 197            .OrderByDescending(x => x.Roi).Take(20).ToList();
 198
 199        var returnRate = byProduct
 200            .Select(p =>
 201            {
 202                var returned = p.Rows.Count(IsReturn);
 203                return new ReturnRateRow(p.Item1, p.ProductName, p.Rows.Count, returned, p.Rows.Count > 0 ? (decimal)ret
 204            })
 205            .Where(x => x.TotalOrders >= 5)
 206            .OrderByDescending(x => x.ReturnRate).Take(20).ToList();
 207
 208        // Время выполнения — только по заказам с обеими датами; нужно минимум 3 таких заказа.
 209        var delivery = orders
 210            .Where(o => !string.IsNullOrEmpty(o.ProductKey) && o.ProcessingDate.HasValue && o.ShippingDate.HasValue)
 211            .GroupBy(o => (o.ProductKey!, ProductName: o.ProductName ?? "Не указано"))
 212            .Select(g => new DeliveryTimeRow(g.Key.Item1, g.Key.ProductName, g.Count(),
 213                (decimal)g.Average(o => (o.ShippingDate!.Value - o.ProcessingDate!.Value).TotalDays)))
 214            .Where(x => x.TotalOrders >= 3)
 215            .OrderByDescending(x => x.AvgDeliveryDays).Take(20).ToList();
 216
 217        var popular = byProduct
 218            .Select(p =>
 219            {
 220                var amount = p.Rows.Sum(o => o.ShipmentAmount ?? 0);
 221                return new PopularProductRow(p.Item1, p.ProductName, p.Rows.Count, amount, p.Rows.Sum(o => o.Quantity ??
 222            })
 223            .OrderByDescending(x => x.TotalOrders).ThenByDescending(x => x.TotalAmount).Take(20).ToList();
 224
 225        var total = orders.Count;
 226        var returnedAll = orders.Count(IsReturn);
 227        var withDates = orders.Where(o => o.ProcessingDate.HasValue && o.ShippingDate.HasValue).ToList();
 228        var statusDistribution = orders
 229            .Where(IsReturn)
 230            .GroupBy(o => o.AppStatusName ?? o.ClientStatusName ?? "Не указан")
 231            .Select(g => new ReturnStatusRow(g.Key, g.Count(), total > 0 ? (decimal)g.Count() / total * 100 : 0))
 232            .OrderByDescending(x => x.Count).ToList();
 233
 234        return new AdditionalMetrics
 235        {
 236            RoiByProduct = roi,
 237            ReturnRateByProduct = returnRate,
 238            AvgDeliveryTimeByProduct = delivery,
 239            PopularProducts = popular,
 240            OverallMetrics = new MetricsSummary(total, returnedAll, total > 0 ? (decimal)returnedAll / total * 100 : 0,
 241                withDates.Count > 0 ? (decimal)withDates.Average(o => (o.ShippingDate!.Value - o.ProcessingDate!.Value).
 242                withDates.Count),
 243            ReturnStatusDistribution = statusDistribution
 244        };
 245    }
 246
 247    // ---------- Сравнительная аналитика ----------
 248
 249    private static double AverageDiscount(IReadOnlyCollection<AnalyticsOrderRow> rows)
 250    {
 251        double total = 0;
 252        var count = 0;
 253        foreach (var o in rows)
 254        {
 255            var min = (double)(o.MinDiscount ?? 0);
 256            var max = (double)(o.MaxDiscount ?? 0);
 257            if (min > 0 || max > 0) { total += (min + max) / 2.0; count++; }
 258        }
 259
 260        return count > 0 ? total / count : 0;
 261    }
 262
 263    private static PeriodTotals Totals(string period, IReadOnlyCollection<AnalyticsOrderRow> rows)
 264    {
 265        var profit = SumProfit(rows, new ProductGroupCalculator.ProfitNormalizer(rows));
 266        var amount = (double)rows.Sum(o => o.ShipmentAmount ?? 0);
 267        var count = rows.Count;
 268        return new PeriodTotals(period, count, amount, rows.Sum(o => o.Quantity ?? 0), profit,
 269            count > 0 ? amount / count : 0, count > 0 ? profit / count : 0, AverageDiscount(rows),
 270            rows.Where(o => !string.IsNullOrEmpty(o.ProductKey)).Select(o => o.ProductKey).Distinct().Count());
 271    }
 272
 273    private static double Change(double current, double previous) =>
 274        previous == 0 ? (current > 0 ? 100 : 0) : (current - previous) / previous * 100;
 275
 276    private static DayPoint Point(string label, IReadOnlyCollection<AnalyticsOrderRow> rows) =>
 277        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    {
 282        var currentStart = new DateTime(now.Year, now.Month, 1);
 283        var currentEnd = currentStart.AddMonths(1).AddDays(-1).AddHours(23).AddMinutes(59).AddSeconds(59);
 284        var previousStart = currentStart.AddMonths(-monthsBack);
 285        var previousEnd = currentStart.AddDays(-1).AddHours(23).AddMinutes(59).AddSeconds(59);
 286        return (currentStart, currentEnd, previousStart, previousEnd);
 287    }
 288
 289    public static ComparativeResult Comparative(IReadOnlyCollection<AnalyticsOrderRow> rows, DateTime now, int monthsBac
 290    {
 291        var (cs, ce, ps, pe) = ComparativeRanges(now, monthsBack);
 292        var current = rows.Where(o => o.ProcessingDate >= cs && o.ProcessingDate <= ce).ToList();
 293        var previous = rows.Where(o => o.ProcessingDate >= ps && o.ProcessingDate <= pe).ToList();
 294        var prevMonth = now.AddMonths(-1);
 295
 296        var cur = Totals(MonthLabel(now.Year, now.Month), current);
 297        var prev = Totals(monthsBack > 1 ? $"Последние {monthsBack} месяцев" : MonthLabel(prevMonth.Year, prevMonth.Mont
 298
 299        List<DayPoint> Days(int year, int month, List<AnalyticsOrderRow> source)
 300        {
 301            var byDay = source.Where(o => o.ProcessingDate.HasValue).ToLookup(o => o.ProcessingDate!.Value.Date);
 302            return Enumerable.Range(1, DateTime.DaysInMonth(year, month))
 303                .Select(day => { var d = new DateTime(year, month, day); return Point(d.ToString("dd.MM"), byDay[d].ToLi
 304                .ToList();
 305        }
 306
 307        var previousSeries = monthsBack > 1
 308            ? previous.Where(o => o.ProcessingDate.HasValue)
 309                .GroupBy(o => (o.ProcessingDate!.Value.Year, o.ProcessingDate!.Value.Month))
 310                .OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month)
 311                .Select(g => Point(MonthLabel(g.Key.Year, g.Key.Month), g.ToList())).ToList()
 312            : Days(prevMonth.Year, prevMonth.Month, previous);
 313
 314        return new ComparativeResult
 315        {
 316            CurrentMonth = cur,
 317            PreviousMonth = prev,
 318            Changes = new PeriodChanges(Change(cur.TotalOrders, prev.TotalOrders), Change(cur.TotalAmount, prev.TotalAmo
 319                Change(cur.TotalQuantity, prev.TotalQuantity), Change(cur.TotalProfit, prev.TotalProfit),
 320                Change(cur.AverageCheck, prev.AverageCheck), Change(cur.AvgProfit, prev.AvgProfit),
 321                Change(cur.AvgDiscount, prev.AvgDiscount), Change(cur.UniqueProducts, prev.UniqueProducts)),
 322            CurrentSeries = Days(now.Year, now.Month, current),
 323            PreviousSeries = previousSeries,
 324            MonthsBack = monthsBack,
 325            ProductKey = productKey
 326        };
 327    }
 328
 329    // ---------- Сезонность ----------
 330
 331    public static SeasonalityResult Seasonality(IReadOnlyCollection<AnalyticsOrderRow> rows)
 332    {
 333        var dated = rows.Where(o => o.ProcessingDate.HasValue).ToList();
 334        return new SeasonalityResult
 335        {
 336            ByMonth = dated.GroupBy(o => (o.ProcessingDate!.Value.Year, o.ProcessingDate!.Value.Month))
 337                .OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month)
 338                .Select(g => new SeasonMonth(MonthLabel(g.Key.Year, g.Key.Month), g.Key.Year, g.Key.Month, g.Count(),
 339                    (double)g.Sum(o => o.ShipmentAmount ?? 0), g.Sum(o => o.Quantity ?? 0))).ToList(),
 340            ByDayOfWeek = dated.GroupBy(o => (int)o.ProcessingDate!.Value.DayOfWeek)
 341                .OrderBy(g => g.Key)
 342                .Select(g => new SeasonDay(g.Key, RussianDays[g.Key], g.Count(), (double)g.Sum(o => o.ShipmentAmount ?? 
 343            ByHour = dated.GroupBy(o => o.ProcessingDate!.Value.Hour)
 344                .OrderBy(g => g.Key)
 345                .Select(g => new SeasonHour(g.Key, g.Count(), (double)g.Sum(o => o.ShipmentAmount ?? 0), g.Sum(o => o.Qu
 346            TotalOrders = dated.Count,
 347            TotalAmount = (double)dated.Sum(o => o.ShipmentAmount ?? 0)
 348        };
 349    }
 350
 351    // ---------- Статусы заказов ----------
 352
 353    public static OrderStatusesResult OrderStatuses(IReadOnlyCollection<AnalyticsOrderRow> rows, string statusType)
 354    {
 355        var app = !string.Equals(statusType, "client", StringComparison.OrdinalIgnoreCase);
 356        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
 364        var withStatus = dated.Select(o => (Row: o, Key: Key(o))).Where(x => x.Key.HasValue).ToList();
 365
 366        var byStatus = withStatus
 367            .GroupBy(x => x.Key!.Value)
 368            .Select(g =>
 369            {
 370                var amount = g.Sum(x => x.Row.ShipmentAmount ?? 0);
 371                return new StatusStat(g.Key.Id, g.Key.Name, g.Count(), (double)amount, g.Count() > 0 ? (double)(amount /
 372            })
 373            .OrderByDescending(x => x.OrdersCount).ToList();
 374
 375        var byMonth = dated
 376            .GroupBy(o => (o.ProcessingDate!.Value.Year, o.ProcessingDate!.Value.Month))
 377            .OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month)
 378            .Select(g => new StatusMonth(MonthLabel(g.Key.Year, g.Key.Month), g.Key.Year, g.Key.Month,
 379                g.Select(o => Key(o)).Where(k => k.HasValue).GroupBy(k => k!.Value)
 380                    .Select(sg => new StatusMonthCount(sg.Key.Name, sg.Count())).ToList()))
 381            .ToList();
 382
 383        return new OrderStatusesResult
 384        {
 385            ByStatus = byStatus,
 386            ByMonth = byMonth,
 387            TotalOrders = dated.Count,
 388            TotalAmount = (double)dated.Sum(o => o.ShipmentAmount ?? 0),
 389            StatusType = app ? "app" : "client"
 390        };
 391    }
 392
 393    // ---------- Склады и регионы ----------
 394
 395    private static List<string> TopBy(IEnumerable<AnalyticsOrderRow> rows, Func<AnalyticsOrderRow, string?> key, Func<IG
 396        rows.Where(o => !string.IsNullOrEmpty(key(o))).GroupBy(o => key(o)!)
 397            .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    {
 402        var amount = rows.Sum(o => o.ShipmentAmount ?? 0);
 403        var profitRows = rows.Where(ProductGroupCalculator.HasProfit).ToList();
 404        var avgProfit = profitRows.Count > 0 ? (decimal)(SumProfit(profitRows, normalizer) / profitRows.Count) : 0;
 405        var discountRows = rows.Where(o => o.MinDiscount.HasValue || o.MaxDiscount.HasValue).ToList();
 406        var avgDiscount = discountRows.Count > 0
 407            ? (decimal)(discountRows.Sum(o => ((double)(o.MinDiscount ?? 0) + (double)(o.MaxDiscount ?? 0)) / 2.0) / dis
 408            : 0;
 409        return new PlaceStat(id, name, rows.Count, rows.Sum(o => o.Quantity ?? 0), amount, rows.Count > 0 ? amount / row
 410            TopBy(rows, o => o.ProductKey, g => g.First().ProductName ?? g.Key),
 411            TopBy(rows, relatedKey, g => g.Key));
 412    }
 413
 414    public static WarehouseRegionResult WarehouseRegion(IReadOnlyCollection<AnalyticsOrderRow> orders)
 415    {
 416        var normalizer = new ProductGroupCalculator.ProfitNormalizer(orders);
 417
 418        var warehouses = orders.Where(o => o.WarehouseId.HasValue)
 419            .GroupBy(o => (o.WarehouseId, Name: o.WarehouseName ?? "Не указан"))
 420            .Select(g => Place(g.Key.WarehouseId, g.Key.Name, g.ToList(), normalizer, o => o.ClientName))
 421            .OrderByDescending(w => w.TotalOrders).ToList();
 422
 423        var regions = orders.Where(o => !string.IsNullOrEmpty(o.DeliveryCity))
 424            .GroupBy(o => o.DeliveryCity!)
 425            .Select(g => Place(null, g.Key, g.ToList(), normalizer, o => o.WarehouseName))
 426            .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
 435        var topRegions = regions.Take(10).Select(r => r.Name).ToHashSet(StringComparer.Ordinal);
 436
 437        return new WarehouseRegionResult
 438        {
 439            Warehouses = warehouses,
 440            Regions = regions,
 441            WarehouseByMonth = ByMonth(orders.Where(o => o.WarehouseId.HasValue), o => o.WarehouseName ?? "Не указан"),
 442            RegionByMonth = ByMonth(orders.Where(o => !string.IsNullOrEmpty(o.DeliveryCity) && topRegions.Contains(o.Del
 443            TotalRegionsCount = regions.Count
 444        };
 445    }
 446
 447    // ---------- Стартовая сводка ----------
 448
 449    public const int SummaryMonths = 24;
 450
 451    public static SummaryResult Summary(IReadOnlyCollection<AnalyticsOrderRow> orders)
 452    {
 453        var normalizer = new ProductGroupCalculator.ProfitNormalizer(orders);
 454        var total = orders.Count;
 455        var amount = orders.Sum(o => o.ShipmentAmount ?? 0);
 456        var profit = ClampDecimal(SumProfit(orders, normalizer));
 457        var returned = orders.Count(IsReturn);
 458        var withDates = orders.Where(o => o.ProcessingDate.HasValue && o.ShippingDate.HasValue).ToList();
 459
 460        var months = orders.Where(o => o.ProcessingDate.HasValue)
 461            .GroupBy(o => (o.ProcessingDate!.Value.Year, o.ProcessingDate!.Value.Month))
 462            .OrderBy(g => g.Key.Year).ThenBy(g => g.Key.Month)
 463            .TakeLast(SummaryMonths).ToList();
 464
 465        var products = ProductGroupCalculator.Build(orders)
 466            .Select(g => new SummaryProduct(g.ProductKey, g.ProductName, g.TotalOrders, g.TotalShipmentAmount, g.AvgProf
 467
 468        var clients = orders.Where(o => !string.IsNullOrEmpty(o.ClientName)).GroupBy(o => o.ClientName!)
 469            .Select(g => new SummaryClient(g.Key, g.Count(), g.Sum(o => o.ShipmentAmount ?? 0), ClampDecimal(SumProfit(g
 470
 471        return new SummaryResult
 472        {
 473            Kpis = new SummaryKpis(total, amount, total > 0 ? amount / total : 0, profit, total > 0 ? (decimal)returned 
 474                withDates.Count > 0 ? (decimal)withDates.Average(o => (o.ShippingDate!.Value - o.ProcessingDate!.Value).
 475                products.Count, clients.Count),
 476            AmountByMonth = months.Select(g => new LabelValue(MonthLabel(g.Key.Year, g.Key.Month), g.Sum(o => o.Shipment
 477            ProfitByMonth = months.Select(g => new LabelValue(MonthLabel(g.Key.Year, g.Key.Month), ClampDecimal(SumProfi
 478            TopProducts = products.OrderByDescending(p => p.TotalAmount).ThenBy(p => p.ProductKey, StringComparer.Ordina
 479            LossProducts = products.Where(p => p.TotalOrders >= 5 && p.AvgProfit < 0).OrderBy(p => p.AvgProfit).ThenBy(p
 480            TopClients = clients.OrderByDescending(c => c.TotalAmount).ThenBy(c => c.ClientName, StringComparer.Ordinal)
 481        };
 482    }
 483}

Methods/Properties

get_Hour()