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